Iam.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. <?php
  2. namespace YdbPlatform\Ydb;
  3. use DateTime;
  4. use DateTimeImmutable;
  5. use Grpc\ChannelCredentials;
  6. use Psr\Log\LoggerInterface;
  7. use YdbPlatform\Ydb\Auth\Implement\AccessTokenAuthentication;
  8. use YdbPlatform\Ydb\Auth\Implement\AnonymousAuthentication;
  9. use YdbPlatform\Ydb\Auth\Implement\JwtWithJsonAuthentication;
  10. use YdbPlatform\Ydb\Auth\Implement\JwtWithPrivateKeyAuthentication;
  11. use YdbPlatform\Ydb\Auth\Implement\MetadataAuthentication;
  12. use YdbPlatform\Ydb\Auth\Implement\OAuthTokenAuthentication;
  13. use YdbPlatform\Ydb\Contracts\IamTokenContract;
  14. use function filter_var;
  15. class Iam implements IamTokenContract
  16. {
  17. use Traits\LoggerTrait;
  18. const IAM_TOKEN_API_URL = 'https://iam.api.cloud.yandex.net/iam/v1/tokens';
  19. const METADATA_URL = 'http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token';
  20. const DEFAULT_TOKEN_EXPIRES_AT = 2; // hours
  21. /**
  22. * @var string
  23. */
  24. protected $iam_token;
  25. /**
  26. * @var int
  27. */
  28. protected $expires_at;
  29. /**
  30. * @var array
  31. */
  32. protected $config = [];
  33. /**
  34. * @var LoggerInterface
  35. */
  36. protected $logger;
  37. /**
  38. * @var int
  39. */
  40. protected $refresh_at;
  41. /**
  42. * @param array $config
  43. * @param LoggerInterface|null $logger
  44. */
  45. public function __construct(array $config = [], LoggerInterface $logger = null)
  46. {
  47. if ($config)
  48. {
  49. $this->config = $this->parseConfig($config);
  50. }
  51. $this->logger = $logger;
  52. $this->initConfig();
  53. }
  54. /**
  55. * @param string $key
  56. * @param string|null $default
  57. * @return mixed|null
  58. */
  59. public function config($key, $default = null)
  60. {
  61. return $this->config[$key] ?? $default;
  62. }
  63. /**
  64. * @param bool $force
  65. * @return string
  66. * @throws Exception
  67. */
  68. public function token($force = false)
  69. {
  70. if ($force || !($token = $this->loadToken()))
  71. {
  72. $token = $this->newToken();
  73. }
  74. return $token;
  75. }
  76. /**
  77. * @return string|null
  78. * @throws Exception
  79. */
  80. public function newToken()
  81. {
  82. $this->logger()->debug('YDB: Obtaining new token...');
  83. $tokenInfo = $this->config('credentials')->getTokenInfo();
  84. $this->iam_token = $tokenInfo->getToken();
  85. $this->expires_at = $tokenInfo->getExpiresAt();
  86. $this->refresh_at = $tokenInfo->getRefreshAt();
  87. $this->saveToken((object)[
  88. "iamToken" => $tokenInfo->getToken(),
  89. "expiresAt" => $tokenInfo->getExpiresAt(),
  90. "refreshAt" => $tokenInfo->getRefreshAt()
  91. ]);
  92. return $tokenInfo->getToken();
  93. }
  94. /**
  95. * @return ChannelCredentials
  96. */
  97. public function getCredentials()
  98. {
  99. if ($this->config('insecure'))
  100. {
  101. return ChannelCredentials::createInsecure();
  102. }
  103. $root_pem_file = $this->config('root_cert_file');
  104. if ($root_pem_file && is_file($root_pem_file))
  105. {
  106. $pem = file_get_contents($root_pem_file);
  107. }
  108. return ChannelCredentials::createSsl($pem ?? null);
  109. }
  110. /**
  111. * @param array $config
  112. * @return array
  113. */
  114. protected function parseConfig(array $config)
  115. {
  116. $parsedConfig = [];
  117. $stringParams = [
  118. 'temp_dir',
  119. 'root_cert_file',
  120. 'access_token',
  121. 'oauth_token',
  122. 'key_id',
  123. 'service_account_id',
  124. 'private_key_file',
  125. 'service_file',
  126. ];
  127. if (isset($config["credentials"])){
  128. $parsedConfig["credentials"] = $config["credentials"];
  129. }
  130. if (isset($config["refresh_token_ratio"])){
  131. $parsedConfig["refresh_token_ratio"] = $config["refresh_token_ratio"];
  132. }
  133. foreach ($stringParams as $param)
  134. {
  135. $parsedConfig[$param] = (string)($config[$param] ?? '');
  136. }
  137. $boolParams = [
  138. 'use_metadata',
  139. 'anonymous',
  140. 'insecure',
  141. ];
  142. foreach ($boolParams as $param)
  143. {
  144. $parsedConfig[$param] = (
  145. isset($config[$param])
  146. && filter_var($config[$param], \FILTER_VALIDATE_BOOLEAN)
  147. );
  148. }
  149. return $parsedConfig;
  150. }
  151. /**
  152. * @return void
  153. * @throws Exception
  154. */
  155. protected function initConfig()
  156. {
  157. if (!$this->config('temp_dir'))
  158. {
  159. $this->config['temp_dir'] = sys_get_temp_dir();
  160. }
  161. if ($this->config('credentials')){
  162. $this->logger()->info('YDB: Authentication method: '. $this->config('credentials')->getName());
  163. }
  164. else if ($this->config('anonymous'))
  165. {
  166. $this->logger()->info('YDB: Authentication method: Anonymous');
  167. $this->config['credentials'] = new AnonymousAuthentication();
  168. $this->config['credentials']->setLogger($this->logger());
  169. }
  170. else if ($this->config('use_metadata'))
  171. {
  172. $this->logger()->info('YDB: Authentication method: Metadata URL');
  173. $this->config['credentials'] = new MetadataAuthentication();
  174. $this->config['credentials']->setLogger($this->logger());
  175. }
  176. else if ($serviceFile = $this->config('service_file'))
  177. {
  178. $this->logger()->info('YDB: Authentication method: SA JSON file');
  179. if (is_file($serviceFile))
  180. {
  181. $this->config['credentials'] = new JwtWithJsonAuthentication($serviceFile);
  182. $this->config['credentials']->setLogger($this->logger());
  183. }
  184. else
  185. {
  186. throw new Exception('Service file [' . $serviceFile . '] is missing.');
  187. }
  188. }
  189. else if ($privateKeyFile = $this->config('private_key_file'))
  190. {
  191. $this->logger()->info('YDB: Authentication method: Private key');
  192. if (is_file($privateKeyFile))
  193. {
  194. $this->config['credentials'] = new JwtWithPrivateKeyAuthentication($this->config('key_id'),
  195. $this->config('service_account_id'), $privateKeyFile);
  196. $this->config['credentials']->setLogger($this->logger());
  197. }
  198. else
  199. {
  200. throw new Exception('Private key [' . $privateKeyFile . '] is missing.');
  201. }
  202. }
  203. else if ($accessToken = $this->config('access_token')){
  204. $this->logger()->info('YDB: Authentication method: Access token');
  205. $this->config['credentials'] = new AccessTokenAuthentication($accessToken);
  206. $this->config['credentials']->setLogger($this->logger());
  207. }
  208. else if ($oauthToken = $this->config('oauth_token'))
  209. {
  210. $this->logger()->info('YDB: Authentication method: OAuth token');
  211. $this->config['credentials'] = new OAuthTokenAuthentication($oauthToken);
  212. $this->config['credentials']->setLogger($this->logger());
  213. }
  214. if ($this->config('credentials') !== null){
  215. $this->config['credentials']->setRefreshTokenRatio($this->config('refresh_token_ratio', 0.1));
  216. }
  217. else
  218. {
  219. throw new Exception('No authentication method is used.');
  220. }
  221. }
  222. /**
  223. * @return string
  224. */
  225. protected function getJwtToken()
  226. {
  227. $now = new DateTimeImmutable;
  228. $token = (new Jwt\Jwt($this->config('private_key'), $this->config('key_id')))
  229. ->issuedBy($this->config('service_account_id'))
  230. ->issuedAt($now)
  231. ->expiresAt($now->modify('+1 hour'))
  232. ->permittedFor(static::IAM_TOKEN_API_URL)
  233. ->getToken();
  234. return $token;
  235. }
  236. /**
  237. * @return string|null
  238. * @throws Exception
  239. */
  240. protected function requestTokenFromMetadata()
  241. {
  242. $curl = curl_init(static::METADATA_URL);
  243. curl_setopt_array($curl, [
  244. CURLOPT_RETURNTRANSFER => 1,
  245. CURLOPT_SSL_VERIFYPEER => 0,
  246. CURLOPT_SSL_VERIFYHOST => 0,
  247. CURLOPT_HEADER => 0,
  248. CURLOPT_HTTPHEADER => [
  249. 'Accept: application/json',
  250. 'Metadata-Flavor:Google',
  251. ],
  252. ]);
  253. $result = curl_exec($curl);
  254. $status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
  255. if ($status === 200)
  256. {
  257. $rawToken = json_decode($result);
  258. if (isset($rawToken->access_token))
  259. {
  260. $token = (object)[
  261. 'iamToken' => $rawToken->access_token,
  262. ];
  263. if (isset($rawToken->expires_in))
  264. {
  265. $token->expiresAt = time() + $rawToken->expires_in;
  266. }
  267. $this->logger()->info('YDB: Obtained new IAM token from Metadata [...' . substr($token->iamToken, -6) . '].');
  268. $this->saveToken($token);
  269. return $token->iamToken;
  270. }
  271. else
  272. {
  273. $this->logger()->error('YDB: Failed to obtain new IAM token from Metadata', [
  274. 'status' => $status,
  275. 'result' => $result,
  276. ]);
  277. throw new Exception('Failed to obtain new iamToken from Metadata: no token was received.');
  278. }
  279. }
  280. else
  281. {
  282. $this->logger()->error('YDB: Failed to obtain new IAM token from Metadata', [
  283. 'status' => $status,
  284. 'result' => $result,
  285. ]);
  286. throw new Exception('Failed to obtain new iamToken from Metadata: response status is ' . $status);
  287. }
  288. }
  289. /**
  290. * @var string
  291. */
  292. protected $token_temp_file;
  293. /**
  294. * @return string
  295. */
  296. protected function getTokenTempFile()
  297. {
  298. if (empty($this->token_temp_file))
  299. {
  300. $temp_dir = $this->config('temp_dir');
  301. if (!is_dir($temp_dir))
  302. {
  303. mkdir($temp_dir, 0600, true);
  304. }
  305. $this->token_temp_file = $temp_dir . '/ydb-iam-' . md5(serialize($this->config)) . '.json';
  306. }
  307. return $this->token_temp_file;
  308. }
  309. /**
  310. * @return string|null
  311. * @throws Exception
  312. */
  313. protected function loadToken()
  314. {
  315. if ($this->iam_token)
  316. {
  317. if ($this->refresh_at <= time()){
  318. try {
  319. return $this->newToken();
  320. } catch (\Exception $e){
  321. return $this->iam_token;
  322. }
  323. }
  324. return $this->iam_token;
  325. }
  326. return $this->loadTokenFromFile();
  327. }
  328. /**
  329. * @return null
  330. */
  331. protected function loadTokenFromFile()
  332. {
  333. $tokenFile = $this->getTokenTempFile();
  334. if (is_file($tokenFile))
  335. {
  336. $token = json_decode(file_get_contents($tokenFile));
  337. if (isset($token->iamToken) && $token->expiresAt > time())
  338. {
  339. $this->iam_token = $token->iamToken;
  340. $this->expires_at = $token->expiresAt;
  341. $this->refresh_at = $token->refreshAt ?? time();
  342. $this->logger()->debug('YDB: Reused token [...' . substr($this->iam_token, -6) . '].');
  343. return $token->iamToken;
  344. }
  345. }
  346. return null;
  347. }
  348. /**
  349. * @param object $token
  350. * @throws Exception
  351. */
  352. protected function saveToken($token)
  353. {
  354. $tokenFile = $this->getTokenTempFile();
  355. $this->iam_token = $token->iamToken;
  356. $this->expires_at = $this->convertExpiresAt($token->expiresAt ?? '');
  357. $this->refresh_at = $token->refreshAt;
  358. $randPath = $tokenFile."-tmp".bin2hex(random_bytes(10));
  359. file_put_contents($randPath, json_encode([
  360. 'iamToken' => $this->iam_token,
  361. 'expiresAt' => $this->expires_at,
  362. 'refreshAt' => $this->refresh_at
  363. ]));
  364. rename($randPath, $tokenFile);
  365. }
  366. /**
  367. * @param string $expiresAt
  368. * @return int
  369. */
  370. protected function convertExpiresAt($expiresAt)
  371. {
  372. if (is_int($expiresAt))
  373. {
  374. return $expiresAt;
  375. }
  376. $time = time() + 60 * 60 * static::DEFAULT_TOKEN_EXPIRES_AT;
  377. if (preg_match('/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?(.*)$/', $expiresAt, $matches))
  378. {
  379. $time = new DateTime($matches[1] . $matches[2]);
  380. $time = (int)$time->format('U');
  381. }
  382. return $time;
  383. }
  384. }