`Merge branch 'master' into chufang-9-9
This commit is contained in:
Your Name
2026-09-10 15:20:45 +08:00
820 changed files with 6668 additions and 13 deletions
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller;
use app\adminapi\service\iam\IamLoginService;
use think\facade\Log;
/** Dedicated optional endpoints: existing account and WeCom actions are untouched. */
class IamController extends BaseAdminController
{
public array $notNeedLogin = ['config', 'start', 'callback', 'exchange'];
private const COOKIE = 'ZYT_IAM_BROWSER';
public function config()
{
return $this->data(IamLoginService::settings())->header(['Cache-Control' => 'no-store']);
}
public function start()
{
try {
$service = new IamLoginService();
$browser = $this->browser();
if ($browser === '') {
$browser = bin2hex(random_bytes(32));
}
setcookie(self::COOKIE, $browser, ['expires' => time() + 600, 'path' => '/adminapi/iam', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']);
return redirect($service->start($browser, $this->request->ip()))->header($this->privateHeaders());
} catch (\Throwable $error) {
return $this->back(['iam_error' => $this->message($error)]);
}
}
public function callback()
{
try {
if ($this->browser() === '' || $this->request->get('error', '') !== '') {
throw new \RuntimeException('授权已取消或浏览器状态过期,请重新登录');
}
$ticket = (new IamLoginService())->callback($this->browser(), (string) $this->request->get('state', ''), (string) $this->request->get('code', ''));
return $this->back(['iam_ticket' => $ticket]);
} catch (\Throwable $error) {
return $this->back(['iam_error' => $this->message($error)]);
}
}
public function exchange()
{
if (!$this->request->isPost()) {
return $this->fail('请使用 POST 兑换登录状态')->code(405);
}
try {
if ($this->browser() === '') {
throw new \RuntimeException('浏览器登录状态已过期,请重新登录');
}
$payload = (new IamLoginService())->exchange($this->browser(), (string) $this->request->post('ticket', ''), (string) $this->request->header('origin', ''));
return $this->data($payload)->header($this->privateHeaders());
} catch (\Throwable $error) {
return $this->fail($this->message($error))->header($this->privateHeaders());
}
}
private function browser(): string
{
$value = (string) ($_COOKIE[self::COOKIE] ?? '');
return preg_match('/^[a-f0-9]{64}$/D', $value) ? $value : '';
}
private function back(array $query)
{
// Relative, fixed path: never derive the return origin from request headers or query strings.
return redirect('/admin/login?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986))->header($this->privateHeaders());
}
private function privateHeaders(): array
{
return ['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer'];
}
private function message(\Throwable $error): string
{
Log::warning('IAM login rejected: ' . get_class($error));
$message = $error->getMessage();
return preg_match('/^[\x{4e00}-\x{9fff}]/u', $message) ? $message : '统一账号登录失败,请重试或使用原账号登录';
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use RuntimeException;
use app\adminapi\logic\auth\AdminLogic;
use think\facade\Config;
use think\facade\Db;
/** Local identity ledger survives IAM timeouts; no names/phones/emails are identity proofs. */
final class IamAccountProvisioner
{
private $imImport;
public function __construct(?callable $imImport = null)
{
$this->imImport = $imImport ?? static function (int $id, string $name): void {
AdminLogic::importDoctorAccountToIm($id, $name);
};
}
public static function identity(array $config, string $subject): string
{
return json_encode([$config['issuer'], $config['application_id'], $subject], JSON_THROW_ON_ERROR);
}
public static function validateContext(array $context, string $subject, string $applicationId): void
{
if (($context['applicationId'] ?? '') !== $applicationId || ($context['localIdentityKey'] ?? '') !== $subject
|| ($context['employee']['oidcSubject'] ?? '') !== $subject || ($context['employee']['status'] ?? '') !== 'active') {
throw new RuntimeException('当前统一账号未获得甄养堂访问权限');
}
}
public function resolve(IamOidcClient $client, array $config, string $subject): int
{
$context = $client->provisioning($subject);
self::validateContext($context, $subject, $config['application_id']);
if (($context['shouldCreateLocalAccount'] ?? null) !== true) {
return IamLoginService::boundAdminId($context, $subject, $config['application_id']);
}
if (($context['externalAccountId'] ?? null) !== '') {
throw new RuntimeException('统一账号绑定状态不一致');
}
$id = $this->createOrResume(self::identity($config, $subject), $context);
// Creation is durable before the remote call. On timeout retry this SAME account, never another one.
$client->bind($subject, $id);
$confirmed = IamLoginService::boundAdminId($client->provisioning($subject), $subject, $config['application_id']);
if ($confirmed !== $id) {
throw new RuntimeException('统一账号绑定冲突,请联系管理员');
}
// Match AdminLogic::add's best-effort doctor IM initialization only after central confirmation.
// importDoctorAccountToIm catches/logs provider failures; it does not grant permissions.
($this->imImport)($id, (string) Db::name('admin')->where('id', $id)->value('name'));
return $id;
}
public function createOrResume(string $identity, array $context): int
{
$key = hash('sha256', $identity);
try {
return Db::transaction(function () use ($key, $identity, $context): int {
$row = Db::name('iam_local_identity')->where('identity_key', $key)->lock(true)->find();
if ($row) {
return $this->existing($row, $identity);
}
// Unique PK serializes concurrent workers, including workers on different servers.
Db::name('iam_local_identity')->insert(['identity_key' => $key, 'identity_value' => $identity,
'admin_id' => null, 'create_time' => time()]);
$role = Db::name('system_role')->where('id', 2)->whereNull('delete_time')->lock(true)->find();
if (!$role || ($role['name'] ?? '') !== '医助' || (isset($role['disable']) && (int) $role['disable'] !== 0)) {
throw new RuntimeException('默认医助角色未就绪,请联系管理员');
}
// Random local login name; never adopt an existing similarly named local account.
$account = 'iam_' . bin2hex(random_bytes(12));
if (Db::name('admin')->where('account', $account)->find()) {
throw new RuntimeException('新账号标识冲突,请重新登录');
}
$name = (string) ($context['employee']['displayName'] ?? '统一账号用户');
$id = (int) Db::name('admin')->insertGetId([
'account' => $account, 'name' => mb_substr($name !== '' ? $name : '统一账号用户', 0, 20),
'password' => create_password(bin2hex(random_bytes(32)), Config::get('project.unique_identification')),
'avatar' => (string) Config::get('project.default_image.admin_avatar', ''),
'root' => 0, 'disable' => 0, 'is_paw' => 0, 'multipoint_login' => 0,
'gender' => 1, 'enable_image_consult' => 1, 'enable_video_consult' => 1, 'enable_charge' => 0,
'create_time' => time(), 'update_time' => time(),
]);
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => 2]);
Db::name('iam_local_identity')->where('identity_key', $key)->update(['admin_id' => $id]);
return $id;
});
} catch (\Throwable $error) {
// A concurrent insert may have won while our transaction rolled back. Only the exact ledger is reusable.
$row = Db::name('iam_local_identity')->where('identity_key', $key)->find();
if ($row) {
return $this->existing($row, $identity);
}
throw $error;
}
}
private function existing(array $row, string $identity): int
{
if (!hash_equals((string) $row['identity_value'], $identity) || (int) $row['admin_id'] <= 0) {
throw new RuntimeException('统一账号本地开户状态异常');
}
$admin = Db::name('admin')->where('id', (int) $row['admin_id'])->find();
if (!$admin || (int) $admin['disable'] !== 0 || !empty($admin['delete_time'])) {
throw new RuntimeException('甄养堂账号已停用,请联系管理员');
}
// Existing permissions and password/WeCom requirements are never rewritten.
return (int) $row['admin_id'];
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use app\adminapi\logic\LoginLogic;
use app\common\enum\AdminTerminalEnum;
use app\common\model\auth\Admin;
use RuntimeException;
use think\facade\Config;
final class IamLoginService
{
private array $config;
private IamOidcClient $client;
private IamLoginTransactionStore $transactions;
public static function settings(): array
{
$config = (array) Config::get('iam', []);
$enabled = in_array(strtolower((string) ($config['enabled'] ?? '')), ['1', 'true', 'yes', 'on'], true);
if (!$enabled) {
return ['enabled' => false, 'loginUrl' => ''];
}
try {
new IamOidcClient($config);
$origin = self::publicOrigin($config);
if (($config['redirect_uri'] ?? '') !== $origin . '/adminapi/iam/callback') {
return ['enabled' => false, 'loginUrl' => ''];
}
return ['enabled' => true, 'loginUrl' => $origin . '/adminapi/iam/start'];
} catch (\Throwable $error) {
return ['enabled' => false, 'loginUrl' => ''];
}
}
public function __construct()
{
if (!self::settings()['enabled']) {
throw new RuntimeException('统一账号快捷登录尚未启用,请使用原账号登录');
}
$this->config = (array) Config::get('iam');
$this->client = new IamOidcClient($this->config);
$this->transactions = new IamLoginTransactionStore(app()->getRuntimePath() . 'iam-login');
}
public function start(string $browser, string $ip): string
{
if (!$this->transactions->allowStart($ip)) {
throw new RuntimeException('快捷登录请求过于频繁,请稍后重试');
}
$state = bin2hex(random_bytes(32));
$nonce = bin2hex(random_bytes(32));
$verifier = bin2hex(random_bytes(32));
$target = $this->client->authorizationUrl($state, $nonce, $verifier);
$this->transactions->put('state', $state, $browser, ['nonce' => $nonce, 'verifier' => $verifier], 600);
return $target;
}
public function callback(string $browser, string $state, string $code): string
{
$transaction = $this->transactions->consume('state', $state, $browser);
if ($code === '' || strlen($code) > 4096) {
throw new RuntimeException('快捷登录回调无效,请重新登录');
}
$claims = $this->client->exchange($code, $transaction['verifier'], $transaction['nonce']);
$this->verifiedAdmin($claims['sub']);
$ticket = bin2hex(random_bytes(32));
$this->transactions->put('ticket', $ticket, $browser, ['subject' => $claims['sub']], 60);
return $ticket;
}
public function exchange(string $browser, string $ticket, string $origin): array
{
if ($origin !== '' && $origin !== self::publicOrigin($this->config)) {
throw new RuntimeException('快捷登录来源无效,请重新登录');
}
$transaction = $this->transactions->consume('ticket', $ticket, $browser);
// Recheck entitlement, binding and local status immediately before issuing the existing business token.
$admin = $this->verifiedAdmin($transaction['subject']);
return (array) (new LoginLogic())->login(['account' => $admin->account, 'terminal' => AdminTerminalEnum::PC]);
}
public static function boundAdminId(array $context, string $subject, string $applicationId): int
{
if (($context['applicationId'] ?? '') !== $applicationId || ($context['localIdentityKey'] ?? '') !== $subject
|| ($context['employee']['oidcSubject'] ?? '') !== $subject || ($context['employee']['status'] ?? '') !== 'active') {
throw new RuntimeException('当前统一账号未获得甄养堂访问权限');
}
$rawId = $context['externalAccountId'] ?? '';
if (!is_string($rawId) && !is_int($rawId)) {
throw new RuntimeException('统一账号绑定格式无效,请联系管理员');
}
$id = (string) $rawId;
if (!preg_match('/^[1-9][0-9]{0,9}$/D', $id) || ($context['shouldCreateLocalAccount'] ?? true) !== false) {
throw new RuntimeException('统一账号尚未开通甄养堂账号,请联系管理员');
}
return (int) $id;
}
private function verifiedAdmin(string $subject): Admin
{
$id = (new IamAccountProvisioner())->resolve($this->client, $this->config, $subject);
$admin = Admin::findOrEmpty($id);
if ($admin->isEmpty() || (int) $admin->disable !== 0 || !empty($admin->getData('delete_time'))) {
throw new RuntimeException('甄养堂账号已停用,请联系管理员');
}
return $admin;
}
private static function publicOrigin(array $config): string
{
$value = rtrim((string) ($config['public_url'] ?? ''), '/');
$parts = parse_url($value);
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])
|| !empty($parts['path']) || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) {
throw new RuntimeException('IAM public origin must be fixed HTTPS');
}
return $value;
}
}
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use RuntimeException;
/** Private, browser-bound, single-use state. File locks work with the existing PHP-FPM deployment. */
final class IamLoginTransactionStore
{
public function __construct(private string $directory)
{
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('IAM transaction storage unavailable');
}
}
public function put(string $kind, string $value, string $browser, array $payload, int $ttl): void
{
$handle = fopen($this->path($kind, $value), 'x');
if ($handle === false) {
throw new RuntimeException('IAM transaction creation failed');
}
try {
chmod($this->path($kind, $value), 0600);
$data = json_encode(['browser' => hash('sha256', $browser), 'expires' => time() + $ttl, 'payload' => $payload], JSON_THROW_ON_ERROR);
if (fwrite($handle, $data) !== strlen($data)) {
throw new RuntimeException('IAM transaction write failed');
}
} finally {
fclose($handle);
}
// Only expired private IAM files are collected, and never a file held by another request.
if (random_int(1, 20) === 1) {
foreach (array_slice(glob($this->directory . '/*.json') ?: [], 0, 200) as $file) {
if (filemtime($file) >= time() - 1200) {
continue;
}
$lock = @fopen($file, 'r+');
if ($lock !== false) {
if (flock($lock, LOCK_EX | LOCK_NB)) {
@unlink($file);
flock($lock, LOCK_UN);
}
fclose($lock);
}
}
}
}
public function consume(string $kind, string $value, string $browser): array
{
$handle = @fopen($this->path($kind, $value), 'r+');
if ($handle === false) {
throw new RuntimeException('登录状态已失效,请重新发起快捷登录');
}
try {
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException('IAM transaction lock failed');
}
$data = json_decode(stream_get_contents($handle), true);
if (!is_array($data) || ($data['expires'] ?? 0) <= time() || !isset($data['browser'])
|| !hash_equals($data['browser'], hash('sha256', $browser)) || !is_array($data['payload'] ?? null)) {
throw new RuntimeException('登录状态已失效,请重新发起快捷登录');
}
// Truncate under the lock before returning, so previously opened file descriptors cannot replay.
if (!ftruncate($handle, 0) || !fflush($handle)) {
throw new RuntimeException('IAM transaction consumption failed');
}
return $data['payload'];
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
public function allowStart(string $ip): bool
{
$file = $this->directory . '/limit-' . hash('sha256', $ip) . '.json';
$handle = fopen($file, 'c+');
if ($handle === false) {
return false;
}
try {
chmod($file, 0600);
if (!flock($handle, LOCK_EX)) {
return false;
}
$data = json_decode(stream_get_contents($handle), true);
if (!is_array($data) || ($data['expires'] ?? 0) <= time()) {
$data = ['count' => 0, 'expires' => time() + 600];
}
if ($data['count'] >= 20) {
return false;
}
++$data['count'];
rewind($handle);
ftruncate($handle, 0);
$encoded = json_encode($data, JSON_THROW_ON_ERROR);
return fwrite($handle, $encoded) === strlen($encoded) && fflush($handle);
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
private function path(string $kind, string $value): string
{
if (!in_array($kind, ['state', 'ticket'], true) || !preg_match('/^[a-f0-9]{64}$/D', $value)) {
throw new RuntimeException('登录状态无效,请重新发起快捷登录');
}
return $this->directory . '/' . $kind . '-' . hash('sha256', $value) . '.json';
}
}
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use RuntimeException;
/** Fixed-origin OIDC client. Transaction storage and account binding belong to the caller. */
final class IamOidcClient
{
private array $config;
private $transport;
private ?array $discovery = null;
/** Transport signature: (method, url, headers, body): ['status'=>int, 'body'=>string]. */
public function __construct(array $config, ?callable $transport = null)
{
foreach (['issuer', 'client_id', 'client_secret', 'redirect_uri', 'api_url', 'application_id', 'application_token'] as $key) {
if (!isset($config[$key]) || !is_string($config[$key]) || trim($config[$key]) === '' || preg_match('/[\r\n]/', $config[$key])) {
throw new RuntimeException('IAM configuration is incomplete');
}
}
$this->config = $config;
$this->transport = $transport;
$this->httpsUrl($config['issuer']);
$this->httpsUrl($config['redirect_uri']);
$this->endpoint($config['api_url']);
if (parse_url($config['issuer'], PHP_URL_QUERY) !== null || parse_url($config['api_url'], PHP_URL_QUERY) !== null) {
throw new RuntimeException('IAM base URL must not contain a query');
}
}
public function authorizationUrl(string $state, string $nonce, string $verifier): string
{
$this->verifier($verifier);
if (strlen($state) < 32 || strlen($nonce) < 32) {
throw new RuntimeException('IAM state and nonce must be random transaction values');
}
$metadata = $this->metadata();
return $metadata['authorization_endpoint'] . (str_contains($metadata['authorization_endpoint'], '?') ? '&' : '?') . http_build_query([
'response_type' => 'code', 'client_id' => $this->config['client_id'],
'redirect_uri' => $this->config['redirect_uri'], 'scope' => 'openid profile',
'state' => $state, 'nonce' => $nonce,
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986);
}
public function exchange(string $code, string $verifier, string $nonce): array
{
$this->verifier($verifier);
if ($code === '' || $nonce === '') {
throw new RuntimeException('IAM authorization response is incomplete');
}
$metadata = $this->metadata();
$response = $this->request('POST', $metadata['token_endpoint'], [
'Content-Type: application/x-www-form-urlencoded',
'Authorization: Basic ' . base64_encode(urlencode($this->config['client_id']) . ':' . urlencode($this->config['client_secret'])),
], http_build_query([
'grant_type' => 'authorization_code', 'code' => $code, 'code_verifier' => $verifier,
'redirect_uri' => $this->config['redirect_uri'],
], '', '&', PHP_QUERY_RFC3986));
if (!isset($response['id_token']) || !is_string($response['id_token']) || strlen($response['id_token']) > 65536) {
throw new RuntimeException('IAM ID token is missing');
}
$jwks = $this->request('GET', $metadata['jwks_uri']);
$keys = [];
$kids = [];
foreach ($jwks['keys'] ?? [] as $key) {
if (is_array($key) && ($key['kty'] ?? '') === 'RSA' && ($key['alg'] ?? 'RS256') === 'RS256'
&& ($key['use'] ?? 'sig') === 'sig' && (!isset($key['key_ops']) || in_array('verify', $key['key_ops'], true))) {
if (!is_string($key['kid'] ?? null) || $key['kid'] === '' || isset($kids[$key['kid']])) {
throw new RuntimeException('IAM signing key identifier is invalid');
}
$kids[$key['kid']] = true;
$key['alg'] = 'RS256';
$keys[] = $key;
}
}
if ($keys === []) {
throw new RuntimeException('IAM signing keys are unavailable');
}
try {
$claims = (array) JWT::decode($response['id_token'], JWK::parseKeySet(['keys' => $keys]));
} catch (\Throwable $error) {
throw new RuntimeException('IAM ID token verification failed');
}
$aud = $claims['aud'] ?? null;
$audiences = is_string($aud) ? [$aud] : (is_array($aud) ? $aud : []);
$now = time();
if (($claims['iss'] ?? null) !== $this->config['issuer']
|| !in_array($this->config['client_id'], $audiences, true)
|| (count($audiences) > 1 && !isset($claims['azp']))
|| (isset($claims['azp']) && $claims['azp'] !== $this->config['client_id'])
|| !is_string($claims['sub'] ?? null) || $claims['sub'] === ''
|| !is_string($claims['nonce'] ?? null) || !hash_equals($nonce, $claims['nonce'])
|| !is_int($claims['exp'] ?? null) || $claims['exp'] <= $now
|| !is_int($claims['iat'] ?? null) || $claims['iat'] > $now
|| (isset($claims['nbf']) && (!is_int($claims['nbf']) || $claims['nbf'] > $now))) {
throw new RuntimeException('IAM ID token claims are invalid');
}
return $claims;
}
public function provisioning(string $subject): array
{
if ($subject === '' || strlen($subject) > 512) {
throw new RuntimeException('IAM subject is invalid');
}
return $this->request('GET', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/'
. rawurlencode($this->config['application_id']) . '/employees/' . rawurlencode($subject) . '/provisioning-context',
['Authorization: Bearer ' . $this->config['application_token']]);
}
public function bind(string $subject, int $adminId): array
{
if ($subject === '' || strlen($subject) > 512 || $adminId <= 0) {
throw new RuntimeException('IAM binding input is invalid');
}
$key = hash('sha256', json_encode([$this->config['issuer'], $this->config['application_id'], $subject, $adminId], JSON_THROW_ON_ERROR));
return $this->request('POST', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/'
. rawurlencode($this->config['application_id']) . '/bindings', [
'Authorization: Bearer ' . $this->config['application_token'],
'Content-Type: application/json', 'X-Request-ID: ' . bin2hex(random_bytes(16)),
'Idempotency-Key: zyt-provision-' . $key,
], json_encode(['employeeSubject' => $subject, 'externalAccountId' => (string) $adminId,
'verificationMethod' => 'oidc_verified_subject_local_creation'], JSON_THROW_ON_ERROR), [201]);
}
private function metadata(): array
{
if ($this->discovery !== null) {
return $this->discovery;
}
$metadata = $this->request('GET', rtrim($this->config['issuer'], '/') . '/.well-known/openid-configuration');
if (($metadata['issuer'] ?? null) !== $this->config['issuer']) {
throw new RuntimeException('IAM discovery issuer mismatch');
}
foreach (['authorization_endpoint', 'token_endpoint', 'jwks_uri'] as $key) {
if (!isset($metadata[$key]) || !is_string($metadata[$key])) {
throw new RuntimeException('IAM discovery endpoint is missing');
}
$this->endpoint($metadata[$key]);
}
return $this->discovery = $metadata;
}
private function verifier(string $verifier): void
{
if (!preg_match('/^[A-Za-z0-9._~-]{43,128}$/D', $verifier)) {
throw new RuntimeException('IAM PKCE verifier is invalid');
}
}
private function httpsUrl(string $url): array
{
$parts = parse_url($url);
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])
|| preg_match('/[\x00-\x20\x7f\\\\]/', $url)) {
throw new RuntimeException('IAM URL must be a fixed HTTPS URL');
}
return $parts;
}
private function endpoint(string $url): void
{
$urlParts = $this->httpsUrl($url);
$issuer = $this->httpsUrl($this->config['issuer']);
if (strtolower($urlParts['host']) !== strtolower($issuer['host']) || ($urlParts['port'] ?? 443) !== ($issuer['port'] ?? 443)) {
throw new RuntimeException('IAM endpoint origin mismatch');
}
}
private function request(string $method, string $url, array $headers = [], string $body = '', array $statuses = [200]): array
{
$this->endpoint($url);
$headers[] = 'Accept: application/json';
if ($this->transport !== null) {
$result = ($this->transport)($method, $url, $headers, $body);
} else {
$curl = curl_init($url);
$response = '';
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 15,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$response): int {
if (strlen($response) + strlen($chunk) > 1048576) {
return 0;
}
$response .= $chunk;
return strlen($chunk);
},
]);
if ($method === 'POST') {
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
$ok = curl_exec($curl);
$result = ['status' => curl_getinfo($curl, CURLINFO_RESPONSE_CODE), 'body' => $ok === false ? false : $response];
curl_close($curl);
}
if (!is_array($result) || !in_array($result['status'] ?? 0, $statuses, true) || !is_string($result['body'] ?? null) || strlen($result['body']) > 1048576) {
throw new RuntimeException('IAM request failed');
}
try {
$data = json_decode($result['body'], true, 64, JSON_THROW_ON_ERROR);
} catch (\Throwable $error) {
throw new RuntimeException('IAM response is invalid');
}
if (!is_array($data) || isset($data['error'])) {
throw new RuntimeException('IAM response is invalid');
}
return $data;
}
}