request->action()); if (in_array($action, ['operation','preflight','batch','retry','selfstart','passwordsetup'], true) && !IamV2Client::feature('management')) { throw new RuntimeException('FEATURE_DISABLED'); } if (in_array($action, ['launchstart','targetprepare','targetexchange'], true) && !IamV2Client::feature('launch')) { throw new RuntimeException('FEATURE_DISABLED'); } $reads = ['status','accounts','identities','operationresult','audit','job','targetconfig']; $read = in_array(strtolower((string) $this->request->action()), $reads, true); if (($read && !$this->request->isGet()) || (!$read && !$this->request->isPost())) { throw new RuntimeException('METHOD_NOT_ALLOWED'); } return $this->data($fn())->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); } catch (\Throwable $e) { $code = preg_match('/^[A-Z_]{3,64}$/D', $e->getMessage()) ? $e->getMessage() : 'SERVICE_UNAVAILABLE'; return $this->fail($code, ['error' => ['code' => $code]], 0, 0)->header(['Cache-Control' => 'no-store']); } } private function session(bool $recent = false): array { if (!$this->request->isGet()) { $expected = IamV2Client::origin((string) Config::get('iam.public_url')); if ((string) $this->request->header('origin') !== $expected) { throw new RuntimeException('INVALID_ORIGIN'); } } return IamV2Source::current((string) $this->request->header('token', ''), $recent); } private function input(): array { return (array) $this->request->post(); } private function requestId(): string { $key = (string) $this->request->header('idempotency-key', ''); if (!preg_match('/^[A-Za-z0-9_-]{16,128}$/D', $key)) { throw new RuntimeException('INVALID_IDEMPOTENCY_KEY'); } return 'zyt-' . $key; } private function serviceAuth(): void { $expected = (string) Config::get('iam.v2_introspection_token', ''); if (!$this->request->isPost() || strlen($expected) < 32 || !hash_equals('Bearer ' . $expected, (string) $this->request->header('authorization', ''))) { throw new RuntimeException('FORBIDDEN'); } } public function status() { if (!IamV2Client::enabled()) { return $this->data(['enabled' => false, 'management_enabled' => false, 'launch_enabled' => false]); } return $this->invokeV2(function () { $token = (string) $this->request->header('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']); $fresh = false; try { $s = IamV2Source::current($token); $fresh = $s['auth_time'] >= time() - 300; } catch (\Throwable $e) { $s = null; } $b = (new IamV2Manager())->binding((int) $legacy['admin_id']); return ['enabled' => true, 'management_enabled' => IamV2Client::feature('management'), 'launch_enabled' => IamV2Client::feature('launch'), 'session_ready' => $s !== null, 'reauth_required' => !$fresh, 'management_capabilities' => IamV2Source::capabilities((int) $legacy['admin_id']), 'protected_review_operator' => IamV2Source::reviewOperator((int) $legacy['admin_id']), 'protected_account' => IamV2Source::protectedAccount((int) $legacy['admin_id'])] + $b; }); } public function reauth() { return $this->invokeV2(function () { if ((string) $this->request->header('origin') !== IamV2Client::origin((string) Config::get('iam.public_url'))) { throw new RuntimeException('INVALID_ORIGIN'); } $s = IamV2Source::reauth((string) $this->request->header('token', ''), (string) $this->request->post('password', '')); return ['expires_at' => $s['auth_time'] + 300]; }); } public function accounts() { return $this->invokeV2(function () { $s = $this->session(); IamV2Source::authorize($s, 'read', [], false); return (new IamV2Manager())->accounts((array) $this->request->get(), (int) $s['admin_id']); }); } public function identities() { return $this->invokeV2(function () { $s = $this->session(); IamV2Source::authorize($s, 'read', [], false); $q = http_build_query(['q' => mb_substr((string) $this->request->get('q', ''), 0, 64), 'page' => max(1, (int) $this->request->get('page', 1)), 'pageSize' => 20], '', '&', PHP_QUERY_RFC3986); return (new IamV2Client())->call('GET', '/identities?' . $q); }); } public function operation() { return $this->invokeV2(fn() => (new IamV2Manager())->operation($this->session(true), $this->input(), $this->requestId())); } public function operationResult() { return $this->invokeV2(fn() => (new IamV2Manager())->result($this->session(), 'operation', (string) $this->request->param('id'))); } public function audit() { return $this->invokeV2(function () { $s = $this->session(); IamV2Source::authorize($s, 'read', [], false); IamV2Source::scope((int) $s['admin_id'], [(int) $this->request->param('id')]); return (new IamV2Client())->call('GET', '/audit?' . http_build_query(['externalAccountId' => (string) (int) $this->request->param('id'), 'page' => max(1, (int) $this->request->get('page', 1)), 'pageSize' => 20])); }); } public function preflight() { return $this->invokeV2(fn() => (new IamV2Manager())->preflight($this->session(true), (array) $this->request->post('items', []))); } public function batch() { return $this->invokeV2(fn() => (new IamV2Manager())->batch($this->session(true), (string) $this->request->post('preview_id'), (array) $this->request->post('indexes', []), $this->requestId())); } public function job() { return $this->invokeV2(fn() => (new IamV2Manager())->result($this->session(), 'job', (string) $this->request->param('id'))); } public function retry() { return $this->invokeV2(fn() => (new IamV2Manager())->result($this->session(true), 'job', (string) $this->request->param('id'), true, (array) $this->request->post('indexes', []))); } public function introspect() { return $this->serviceResult(function () { $this->serviceAuth(); $b = $this->input(); return IamV2Source::introspect((string) ($b['proof'] ?? ''), (string) ($b['purpose'] ?? ''), $b['transactionId'] ?? null); }); } private function serviceResult(callable $fn) { try { if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); } if (!$this->request->isPost()) { throw new RuntimeException('METHOD_NOT_ALLOWED'); } return json($fn())->header(['Cache-Control' => 'no-store']); } catch (\Throwable $e) { return json(['active' => false], 403)->header(['Cache-Control' => 'no-store']); } } public function checkSession() { return $this->serviceResult(function () { $this->serviceAuth(); $b = $this->input(); return IamV2Source::liveStatus((string) ($b['sessionId'] ?? ''), (int) ($b['sessionVersion'] ?? 0), (string) ($b['purpose'] ?? ''), (array) ($b['targetLocalIds'] ?? [])); }); } public function launchStart() { return $this->invokeV2(function () { $s = $this->session(); (new IamV2Manager())->assertLinked((int) $s['admin_id']); IamV2Source::check($s['session_id'], (int) $s['version']); $intent = bin2hex(random_bytes(32)); IamV2Flow::put('launch', $intent, ['sessionId' => $s['session_id'], 'sessionVersion' => $s['version']]); setcookie('ZYT_IAM_V2_INTENT', $intent, ['expires' => time() + 300, 'path' => '/adminapi/iam/v2', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']); return ['url' => IamV2Client::origin((string) Config::get('iam.erp_url')) . '/iam/launch?source=zyt&intent=' . $intent]; }); } public function launchContinue() { try { IamV2Source::requirePurpose('launch'); $client = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? '')); $intent = (string) $this->request->get('intent', ''); $cookie = (string) ($_COOKIE['ZYT_IAM_V2_INTENT'] ?? ''); if ($intent === '' || !hash_equals($cookie, $intent)) { throw new RuntimeException('ACCOUNT_MISMATCH'); } $flow = IamV2Flow::take('launch', $intent, $s['session_id']); IamV2Source::check($s['session_id'], (int) $flow['sessionVersion']); $tx = (string) $this->request->get('transaction_id', ''); $transaction = $client->call('GET', '/launch-transactions/' . rawurlencode($tx)); if (($transaction['sourceApplicationId'] ?? '') !== 'zyt' || ($transaction['targetApplicationId'] ?? '') !== 'zmxy-erp') { throw new RuntimeException('ACCOUNT_MISMATCH'); } (new IamV2Manager($client))->assertLinked((int) $s['admin_id']); $grant = $client->call('POST', '/launch-grants', ['transactionId' => $tx, 'proof' => IamV2Source::proof($s, 'launch', [(int) $s['admin_id']], [], $tx)]); $erpOrigin = IamV2Client::origin((string) Config::get('iam.erp_url')); $delivery = IamV2Client::https((string) Config::get('iam.erp_callback_url') ?: $erpOrigin . '/api/erp/iam/v2/launch/callback'); if (!in_array($delivery, [$erpOrigin . '/api/erp/iam/v2/launch/callback', $erpOrigin . '/basic-api/api/erp/iam/v2/launch/callback'], true)) { throw new RuntimeException('INVALID_FIXED_URL'); } if (($grant['deliveryUrl'] ?? '') !== $delivery) { throw new RuntimeException('INVALID_FIXED_URL'); } return response(IamV2Flow::form($delivery, ['transaction_id' => $tx, 'grant' => $grant['grant']]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'strict-origin']); } catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=launch_failed')->header(['Cache-Control' => 'no-store']); } } private function selfConfig(): array { $c = (array) Config::get('iam'); $c['redirect_uri'] = IamV2Client::origin($c['public_url']) . '/adminapi/iam/v2/self-link/callback'; return $c; } public function selfStart() { return $this->invokeV2(function () { $s = $this->session(true); $a = IamV2Source::admin((int) $s['admin_id']); if (IamV2Source::protectedAccount((int) $a['id'])) { throw new RuntimeException('PROTECTED_ACCOUNT_REVIEW_REQUIRED'); } $b = (new IamV2Manager())->binding((int) $s['admin_id'], true); if (($b['binding']['state'] ?? '') === 'verified') { throw new RuntimeException('BINDING_CONFLICT'); } $state = bin2hex(random_bytes(32)); $nonce = bin2hex(random_bytes(32)); $verifier = bin2hex(random_bytes(32)); IamV2Flow::put('self_link', $state, ['sessionId' => $s['session_id'], 'nonce' => $nonce, 'verifier' => $verifier, 'version' => $b['binding']['version'] ?? null]); return ['url' => (new IamOidcClient($this->selfConfig()))->authorizationUrl($state, $nonce, $verifier) . '&prompt=login&max_age=300']; }); } public function selfCallback() { try { IamV2Source::requirePurpose('self_link'); $central = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? '')); $flow = IamV2Flow::take('self_link', (string) $this->request->get('state', ''), $s['session_id']); $oidc = new IamOidcClient($this->selfConfig()); $oidc->exchange((string) $this->request->get('code', ''), $flow['verifier'], $flow['nonce']); $proof = IamV2Source::proof($s, 'self_link', [(int) $s['admin_id']], [], null, ['oidcNonce' => $flow['nonce']]); $body = ['idToken' => $oidc->verifiedIdToken(), 'nonce' => $flow['nonce'], 'proof' => $proof, 'externalAccountId' => (string) $s['admin_id']]; if ($flow['version'] !== null) { $body['expectedVersion'] = (int) $flow['version']; } $central->call('POST', '/self-link', $body); (new IamV2Manager($central))->binding((int) $s['admin_id'], true); return redirect('/admin/user/setting?iam_result=linked')->header(['Cache-Control' => 'no-store']); } catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=link_failed')->header(['Cache-Control' => 'no-store']); } } public function passwordSetup() { return $this->invokeV2(function () { $s = $this->session(true); $b = (new IamV2Manager())->assertLinked((int) $s['admin_id']); if (!($b['employee']['passwordSetupPending'] ?? false)) { throw new RuntimeException('PASSWORD_ALREADY_SET'); } $result = (new IamV2Client())->call('POST', '/password-setup', ['proof' => IamV2Source::proof($s, 'password_setup', [(int) $s['admin_id']])]); $url = parse_url((string) ($result['url'] ?? '')); $portal = parse_url(IamV2Client::origin((string) Config::get('iam.portal_url'))); if (!$url || ($url['scheme'] ?? '') !== 'https' || ($url['host'] ?? '') !== $portal['host'] || ($url['port'] ?? 443) !== ($portal['port'] ?? 443) || isset($url['user']) || isset($url['pass']) || ($url['path'] ?? '') !== '/portal/password-setup') { throw new RuntimeException('INVALID_FIXED_URL'); } $intent = bin2hex(random_bytes(32)); IamV2Flow::put('password_setup', $intent, ['sessionId' => $s['session_id'], 'requestId' => $result['requestId']], 900); setcookie('ZYT_IAM_PASSWORD_INTENT', $intent, ['expires' => time() + 900, 'path' => '/adminapi/iam/v2', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']); return $result; }); } public function passwordContinue() { try { IamV2Source::requirePurpose('password_setup'); $client = new IamV2Client(); $s = IamV2Source::check((string) ($_COOKIE[IamV2Source::COOKIE] ?? '')); $f = IamV2Flow::take('password_setup', (string) ($_COOKIE['ZYT_IAM_PASSWORD_INTENT'] ?? ''), $s['session_id']); $id = (string) $this->request->get('request_id', ''); $state = (string) $this->request->get('state', ''); if ($id !== $f['requestId'] || !preg_match('/^[A-Za-z0-9_-]{32,256}$/D', $state)) { throw new RuntimeException('ACCOUNT_MISMATCH'); } $proof = IamV2Source::proof($s, 'password_setup', [(int) $s['admin_id']], [], $id, ['state' => $state]); $url = IamV2Client::origin((string) Config::get('iam.portal_url')) . '/portal/password-setup/authorize'; return response(IamV2Flow::form($url, ['request_id' => $id, 'state' => $state, 'proof' => $proof]))->header(['Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => 'no-store', 'Referrer-Policy' => 'strict-origin']); } catch (\Throwable $e) { return redirect('/admin/user/setting?iam_result=password_failed'); } } private function targetOrigin(): void { if (!$this->request->isPost() || (string) $this->request->header('origin') !== IamV2Client::origin((string) Config::get('iam.public_url'))) { throw new RuntimeException('INVALID_ORIGIN'); } } public function targetConfig() { if (!IamV2Client::enabled()) { return $this->data(['enabled' => false, 'management_enabled' => false, 'launch_enabled' => false]); } return $this->invokeV2(function () { $source = (string) $this->request->get('source', 'iam-portal'); return ['enabled' => IamV2Client::feature('launch'), 'global_enabled' => true, 'management_enabled' => IamV2Client::feature('management'), 'launch_enabled' => IamV2Client::feature('launch'), 'restart_url' => IamV2Client::origin((string) Config::get('iam.portal_url')) . ($source === 'iam-admin' ? '/admin' : '/portal')]; }); } public function targetPrepare() { return $this->invokeV2(function () { $this->targetOrigin(); $out = (new IamV2Target())->prepare((string) $this->request->post('source', ''), (string) $this->request->post('intent', ''), (string) $this->request->header('token', ''), (string) ($_COOKIE[IamV2Source::COOKIE] ?? '')); setcookie(IamV2Target::COOKIE, $out['browser'], ['expires' => time() + 300, 'path' => '/adminapi/iam/v2/launch', 'secure' => true, 'httponly' => true, 'samesite' => 'None']); return ['url' => $out['url']]; }); } public function targetCallback() { try { if (!$this->request->isPost()) { throw new RuntimeException('METHOD_NOT_ALLOWED'); } $out = (new IamV2Target())->callback((string) $this->request->post('transaction_id', ''), (string) $this->request->post('grant', ''), (string) ($_COOKIE[IamV2Target::COOKIE] ?? ''), (string) $this->request->header('origin', '')); return redirect('/admin/iam/launch?' . http_build_query($out))->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); } catch (\Throwable $e) { $code = preg_match('/^[A-Z_]{3,64}$/D', $e->getMessage()) ? $e->getMessage() : 'SERVICE_UNAVAILABLE'; return redirect('/admin/iam/launch?iam_error=' . rawurlencode($code))->header(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); } } public function targetExchange() { return $this->invokeV2(function () { $this->targetOrigin(); return (new IamV2Target())->exchange((string) $this->request->post('ticket', ''), (string) ($_COOKIE[IamV2Target::COOKIE] ?? ''), (string) $this->request->header('token', ''), (string) ($_COOKIE[IamV2Source::COOKIE] ?? '')); }); } public function targetReset() { return $this->invokeV2(function () { $this->targetOrigin(); IamV2Source::revokeSession((string) ($_COOKIE[IamV2Source::COOKIE] ?? '')); setcookie(IamV2Source::COOKIE, '', ['expires' => 1, 'path' => '/adminapi', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax']); setcookie(IamV2Target::COOKIE, '', ['expires' => 1, 'path' => '/adminapi/iam/v2/launch', 'secure' => true, 'httponly' => true, 'samesite' => 'None']); return ['reset' => true]; }); } public function revocation() { return $this->serviceResult(fn() => \app\adminapi\service\iam\v2\IamV2Events::accept( (string) $this->request->getContent(), (string) $this->request->header('x-iam-signature', ''), (string) $this->request->header('x-iam-event-id', ''), (string) Config::get('iam.v2_event_secret', ''))); } }