i
`Merge branch 'master' into chufang-9-9
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user