186 lines
15 KiB
PHP
186 lines
15 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};
|
|
|
|
/** 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'];
|
|
private function invokeV2(callable $fn)
|
|
{
|
|
try {
|
|
if (!IamV2Client::enabled()) { throw new RuntimeException('FEATURE_DISABLED'); }
|
|
$reads = ['status','accounts','identities','operationresult','audit','job'];
|
|
$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]])->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]); }
|
|
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, 'session_ready' => $s !== null, 'reauth_required' => !$fresh,
|
|
'management_capabilities' => IamV2Source::capabilities((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(); $s = IamV2Source::check((string) ($b['sessionId'] ?? ''), (int) ($b['sessionVersion'] ?? 0));
|
|
return ['active' => true, 'actorLocalId' => (string) $s['admin_id'], 'authMethod' => $s['auth_method'], 'authTime' => (int) $s['auth_time'], 'expiresAt' => (int) $s['expires_at']]; });
|
|
}
|
|
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 {
|
|
$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)]);
|
|
$delivery = IamV2Client::origin((string) Config::get('iam.erp_url')) . '/api/erp/iam/v2/launch/callback';
|
|
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 {
|
|
$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 {
|
|
$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'); }
|
|
}
|
|
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', '')));
|
|
}
|
|
}
|