Files
zyt/server/app/adminapi/controller/IamV2Controller.php
T

246 lines
19 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\controller;
use RuntimeException;
use think\facade\Config;
use think\facade\Db;
use app\adminapi\service\iam\IamOidcClient;
use app\adminapi\service\iam\v2\{IamV2Client, IamV2Source, IamV2Manager, IamV2Flow, IamV2Target};
/** Every endpoint authorizes itself. Never relies on legacy unregistered-route allow rules. */
final class IamV2Controller extends BaseAdminController
{
public array $notNeedLogin = ['status', 'introspect', 'checkSession', 'revocation', 'launchContinue', 'selfCallback', 'passwordContinue', 'targetConfig', 'targetPrepare', 'targetCallback', 'targetExchange', 'targetReset'];
private function invokeV2(callable $fn)
{
try {
if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); }
$action = strtolower((string) $this->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' => 'no-referrer']);
} 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' => 'no-referrer']);
} 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', '')));
}
}