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()]); } }