184 lines
13 KiB
PHP
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()]);
|
|
}
|
|
}
|