Files
2026-11-06 13:37:12 +08:00

184 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\service\iam\v2;
use RuntimeException;
use think\facade\Config;
use think\facade\Db;
use app\adminapi\logic\LoginLogic;
use app\adminapi\logic\auth\AdminLogic;
use app\adminapi\service\AdminTokenService;
use app\adminapi\service\iam\{IamAccountProvisioner, IamOidcClient};
use app\common\enum\AdminTerminalEnum;
use app\common\model\auth\Admin;
use app\common\service\FileService;
/** ZYT target accepts only the person's own IAM admin/portal source, never ERP. */
final class IamV2Target
{
public const COOKIE = 'ZYT_IAM_V2_TARGET';
private array $settings;
public function __construct(private ?IamV2Client $client = null, private $resolve = null, private $login = null)
{
$this->settings = (array) Config::get('iam');
if (!IamV2Client::feature('launch', $this->settings)) { throw new RuntimeException('FEATURE_DISABLED'); }
$this->client ??= new IamV2Client();
$this->resolve ??= fn(array $result) => $this->resolveAdmin($result);
$this->login ??= function (int $id, array $authentication): array {
$authTime = self::authTime($authentication['authTime'] ?? null);
$result = $this->loginVerifiedAdmin($id);
IamV2Source::enroll($result['token'], 'iam_launch', true, $authTime);
return $result;
};
}
/** Only exchange's verified, one-time IAM ticket may reach this session issuer. */
private function loginVerifiedAdmin(int $id): array
{
return Db::transaction(static function () use ($id): array {
$admin = Admin::where('id', $id)->lock(true)->findOrEmpty();
if ($admin->isEmpty() || (int) $admin['disable'] !== 0 || !empty($admin['delete_time'])) {
throw new RuntimeException('ACCOUNT_DISABLED');
}
$admin->login_time = time();
$admin->login_ip = request()->ip();
$admin->save();
$adminInfo = AdminTokenService::setToken($admin->id, AdminTerminalEnum::PC, $admin->multipoint_login);
if (!$adminInfo || empty($adminInfo['token'])) { throw new RuntimeException('ACCOUNT_DISABLED'); }
$avatar = $admin->avatar ?: Config::get('project.default_image.admin_avatar');
return [
'name' => $adminInfo['name'],
'avatar' => FileService::getFileUrl($avatar),
'role_name' => $adminInfo['role_name'],
'token' => $adminInfo['token'],
'is_paw' => $admin->is_paw ?? 1,
'need_bind_work_wechat' => LoginLogic::adminMustBindWorkWechat([
'root' => (int) ($admin->root ?? 0),
'work_wechat_userid' => (string) ($admin->work_wechat_userid ?? ''),
]),
];
});
}
public static function authTime($value): int
{
$time = is_int($value) ? $value : (is_string($value) ? strtotime($value) : false);
if (!$time || $time < 0 || $time > time() + 60) { throw new RuntimeException('INVALID_AUTHENTICATION'); }
return $time;
}
private function registered(string $source): string
{
$sources = $this->settings['v2_sources'] ?? [];
if (!in_array($source, ['iam-admin', 'iam-portal'], true) || !is_array($sources) || !isset($sources[$source])) { throw new RuntimeException('NOT_AUTHORIZED'); }
return IamV2Client::https((string) $sources[$source]);
}
public static function existing(string $token, string $sourceSession = ''): ?int
{
$id = null;
if ($token !== '') {
$legacy = Db::name('admin_session')->where('token', $token)->where('expire_time', '>', time())->find();
if (!$legacy) { throw new RuntimeException('REAUTH_REQUIRED'); }
IamV2Source::admin((int) $legacy['admin_id']); $id = (int) $legacy['admin_id'];
}
if ($sourceSession !== '') {
try { $session = IamV2Source::check($sourceSession); } catch (RuntimeException $e) { $session = null; }
if ($session) {
if ($id !== null && $id !== (int) $session['admin_id']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$id = (int) $session['admin_id'];
}
}
return $id;
}
public function prepare(string $source, string $intent, string $token, string $sourceSession = ''): array
{
$registered = $this->registered($source);
if (!preg_match('/^[A-Za-z0-9_-]{32,256}$/D', $intent)) { throw new RuntimeException('INVALID_REQUEST'); }
$existing = self::existing($token, $sourceSession);
$browser = bin2hex(random_bytes(32)); $state = bin2hex(random_bytes(32)); $verifier = bin2hex(random_bytes(32));
$tx = $this->client->call('POST', '/launch-transactions', ['sourceApplicationId' => $source, 'stateHash' => hash('sha256', $state),
'codeChallenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=')]);
$id = $tx['transactionId'] ?? '';
if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id)) { throw new RuntimeException('INVALID_RESPONSE'); }
$actual = parse_url((string) ($tx['sourceContinueUrl'] ?? '')); $fixed = parse_url($registered);
if (!$actual || ($actual['scheme'] ?? '') !== $fixed['scheme'] || ($actual['host'] ?? '') !== $fixed['host']
|| ($actual['port'] ?? 443) !== ($fixed['port'] ?? 443) || ($actual['path'] ?? '') !== ($fixed['path'] ?? '')
|| isset($actual['user']) || isset($actual['pass']) || isset($actual['fragment'])) { throw new RuntimeException('INVALID_FIXED_URL'); }
parse_str($actual['query'] ?? '', $query);
if (count($query) !== 1 || ($query['transaction_id'] ?? null) !== $id) { throw new RuntimeException('INVALID_RESPONSE'); }
IamV2Flow::put('target_launch', $id, ['sessionId' => hash('sha256', $browser), 'source' => $source, 'verifier' => $verifier,
'stateHash' => hash('sha256', $state), 'expectedAdminId' => $existing]);
return ['url' => $registered . '?' . http_build_query(['transaction_id' => $id, 'intent' => $intent], '', '&', PHP_QUERY_RFC3986), 'browser' => $browser];
}
public function callback(string $transaction, string $grant, string $browser, string $origin): array
{
if (!preg_match('/^[a-f0-9]{64}$/D', $browser) || !preg_match('/^[A-Za-z0-9_-]{43}$/D', $grant)) { throw new RuntimeException('TRANSACTION_EXPIRED'); }
$flow = IamV2Flow::peek('target_launch', $transaction, hash('sha256', $browser));
$source = parse_url($this->registered($flow['source']));
$expectedOrigin = $source['scheme'] . '://' . $source['host'] . (isset($source['port']) ? ':' . $source['port'] : '');
if ($origin !== $expectedOrigin) { throw new RuntimeException('INVALID_ORIGIN'); }
IamV2Flow::take('target_launch', $transaction, hash('sha256', $browser));
$result = $this->client->call('POST', '/launch-grants/exchange', ['transactionId' => $transaction, 'grant' => $grant, 'codeVerifier' => $flow['verifier']]);
$employee = $result['employee'] ?? []; $auth = $result['authentication'] ?? [];
if (($employee['status'] ?? '') !== 'active' || !is_string($employee['oidcSubject'] ?? null) || $employee['oidcSubject'] === ''
|| empty($employee['id']) || !($result['access']['allowed'] ?? false) || ($auth['sourceApplicationId'] ?? '') !== $flow['source']
|| empty($auth['sessionId']) || !is_string($auth['authMethod'] ?? null) || $auth['authMethod'] === '' || !is_int($auth['sessionVersion'] ?? null) || $auth['sessionVersion'] < 1) { throw new RuntimeException('NOT_AUTHORIZED'); }
self::authTime($auth['authTime'] ?? null);
if ($flow['expectedAdminId'] !== null && (string) ($result['binding']['externalAccountId'] ?? '') !== (string) $flow['expectedAdminId']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$id = (int) ($this->resolve)($result);
if ($id < 1 || ($flow['expectedAdminId'] !== null && $id !== (int) $flow['expectedAdminId'])) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
IamV2Source::admin($id);
$current = (new IamV2Manager($this->client))->assertLinked($id);
if (($current['employee']['id'] ?? '') !== $employee['id'] || ($current['employee']['oidcSubject'] ?? '') !== $employee['oidcSubject']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$ticket = bin2hex(random_bytes(32));
IamV2Flow::put('target_ticket', $ticket, ['sessionId' => hash('sha256', $browser), 'adminId' => $id, 'expectedAdminId' => $flow['expectedAdminId'],
'employeeId' => $employee['id'], 'bindingVersion' => (int) $current['binding']['version'], 'source' => $flow['source'], 'authentication' => $auth], 60);
return ['ticket' => $ticket, 'source' => $flow['source']];
}
private function resolveAdmin(array $result): int
{
$employee = $result['employee']; $subject = $employee['oidcSubject'];
$client = new IamOidcClient($this->settings); $context = $client->provisioning($subject);
if (($context['employee']['id'] ?? '') !== $employee['id']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$binding = $result['binding'] ?? null;
if ($binding && (($binding['applicationId'] ?? '') !== 'zyt' || ($binding['state'] ?? '') !== 'verified'
|| (string) ($context['externalAccountId'] ?? '') !== (string) $binding['externalAccountId']
|| (int) ($context['bindingVersion'] ?? 0) !== (int) $binding['version'])) { throw new RuntimeException('BINDING_CONFLICT'); }
if (!$binding && ($context['externalAccountId'] ?? '') !== '') { throw new RuntimeException('BINDING_CONFLICT'); }
$id = (new IamAccountProvisioner())->resolve($client, $this->settings, $subject);
$current = (new IamV2Manager($this->client))->assertLinked($id);
if (($current['employee']['id'] ?? '') !== $employee['id'] || ($current['employee']['oidcSubject'] ?? '') !== $subject
|| ($binding && (int) $current['binding']['version'] !== (int) $binding['version'])) { throw new RuntimeException('BINDING_CONFLICT'); }
return $id;
}
public function exchange(string $ticket, string $browser, string $token, string $sourceSession = ''): array
{
$flow = IamV2Flow::peek('target_ticket', $ticket, hash('sha256', $browser));
$existing = self::existing($token, $sourceSession);
if (($flow['expectedAdminId'] !== null && $existing !== (int) $flow['expectedAdminId']) || ($existing !== null && $existing !== (int) $flow['adminId'])) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
$current = (new IamV2Manager($this->client))->assertLinked((int) $flow['adminId']);
if (($current['employee']['id'] ?? '') !== $flow['employeeId']) { throw new RuntimeException('ACCOUNT_MISMATCH'); }
if ((int) $current['binding']['version'] !== (int) $flow['bindingVersion']) { throw new RuntimeException('BINDING_CONFLICT'); }
IamV2Flow::take('target_ticket', $ticket, hash('sha256', $browser));
$result = ($this->login)((int) $flow['adminId'], $flow['authentication']);
$hash = hash('sha256', $result['token']);
$values = ['token_hash' => $hash, 'admin_id' => (int) $flow['adminId'], 'employee_id' => $flow['employeeId'],
'source_application_id' => $flow['source'], 'source_binding_version' => (int) ($flow['authentication']['sourceBindingVersion'] ?? 0), 'checked_at' => time()];
$old = Db::name('iam_v2_target_session')->where('token_hash', $hash)->find();
if ($old) { Db::name('iam_v2_target_session')->where('token_hash', $hash)->update($values); }
else { Db::name('iam_v2_target_session')->insert($values); }
return $result;
}
public static function compensate(string $token): void
{
$hash = hash('sha256', $token); $row = Db::name('iam_v2_target_session')->where('token_hash', $hash)->find();
if (!$row || $row['checked_at'] > time() - 30) { return; }
$query = http_build_query(['employeeId' => $row['employee_id'], 'sourceApplicationId' => $row['source_application_id'],
'sourceBindingVersion' => (int) $row['source_binding_version']], '', '&', PHP_QUERY_RFC3986);
$check = (new IamV2Client())->call('GET', '/launch-authorizations/check?' . $query);
if (!($check['allowed'] ?? false)) {
Db::name('iam_v2_source_session')->where('token_hash', $hash)->where('revoked', 0)->inc('version')->update(['revoked' => 1]);
AdminLogic::expireToken($token); throw new RuntimeException('NOT_AUTHORIZED');
}
Db::name('iam_v2_target_session')->where('token_hash', $hash)->update(['checked_at' => time()]);
}
}