fix(iam): align cross-app proofs and add native IAM target launch

This commit is contained in:
2026-10-29 18:49:32 +08:00
parent dcb0db8275
commit 7cbc679287
25 changed files with 837 additions and 99 deletions
@@ -7,17 +7,17 @@ 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};
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'];
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'); }
$reads = ['status','accounts','identities','operationresult','audit','job'];
$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']);
@@ -57,7 +57,9 @@ final class IamV2Controller extends BaseAdminController
$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;
'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()
@@ -100,8 +102,9 @@ final class IamV2Controller extends BaseAdminController
}
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']]; });
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()
{
@@ -123,7 +126,9 @@ final class IamV2Controller extends BaseAdminController
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';
$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']); }
@@ -176,6 +181,55 @@ final class IamV2Controller extends BaseAdminController
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]); }
return $this->invokeV2(function () {
$source = (string) $this->request->get('source', 'iam-portal');
return ['enabled' => true, '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(