diff --git a/docs/iam-quick-login.md b/docs/iam-quick-login.md index c1903fc22..774fd9f5d 100644 --- a/docs/iam-quick-login.md +++ b/docs/iam-quick-login.md @@ -51,3 +51,23 @@ NODE_PATH=admin/node_modules node server/tests/IamLoginUiContractTest.mjs ``` 回退时先关闭 IAM 开关,再恢复本次代码和静态入口备份,不清空会话、用户、角色、业务数据库或全部缓存。新创建的员工与医助账号保留,由管理员决定是否停用。 + +## 2026-09-10: authorized first-login provisioning + +Deploy `server/database/migrations/20260910_iam_local_identity.sql` before the adapter update (adjust the `zyt_` prefix only when configured differently). This additive InnoDB ledger is keyed by SHA-256 of the exact issuer/application/subject tuple and uniquely associates a new local account. Keep the ledger when rolling code back; never drop it or delete real accounts as a code rollback. + +The authenticated provisioning-context API is the authority for active employee + application grant. Only an explicit `shouldCreateLocalAccount=true` with an empty external ID creates an account. The local transaction inserts the immutable ledger, a random local account name and password, and role **2 / 医助** only; a missing, deleted, renamed or disabled default role fails closed. Neither name, phone nor email merges accounts. Existing explicit bindings use existing permissions without modification. Password setup (`is_paw=0`), non-root status, and original WeCom binding gates remain intact. The normal physician IM import runs best-effort after central binding confirmation, matching `AdminLogic::add` semantics; provider errors are logged by the existing helper. + +After local commit, the adapter uses application authentication and stable idempotency metadata to POST bindings, accepts HTTP 201, then reads provisioning-context again and requires the exact created ID before issuing a login ticket. Remote failures keep the local ledger for retry, with no business token. Parallel workers cannot create another ledger/account for that immutable identity. A transient DB deadlock can fail one login attempt; retry resumes the winning account. + +The IAM server must reject changing an existing binding to a different external ID on this endpoint (rather than an upsert overwrite). Ship that conflict fix before this adapter. Admin-driven deliberate rebindings require a separate verified workflow. + +Focused local tests: + +```sh +php server/tests/IamOidcClientTest.php +php server/tests/IamLoginTransactionTest.php +php server/tests/IamProvisioningTest.php +# Dedicated disposable local MariaDB only; drops four fixture tables in iam_fixture. +IAM_TEST_MYSQL=1 php server/tests/IamProvisioningTest.php +``` diff --git a/server/app/adminapi/service/iam/IamAccountProvisioner.php b/server/app/adminapi/service/iam/IamAccountProvisioner.php new file mode 100644 index 000000000..99984e6ae --- /dev/null +++ b/server/app/adminapi/service/iam/IamAccountProvisioner.php @@ -0,0 +1,116 @@ +imImport = $imImport ?? static function (int $id, string $name): void { + AdminLogic::importDoctorAccountToIm($id, $name); + }; + } + + public static function identity(array $config, string $subject): string + { + return json_encode([$config['issuer'], $config['application_id'], $subject], JSON_THROW_ON_ERROR); + } + + public static function validateContext(array $context, string $subject, string $applicationId): void + { + if (($context['applicationId'] ?? '') !== $applicationId || ($context['localIdentityKey'] ?? '') !== $subject + || ($context['employee']['oidcSubject'] ?? '') !== $subject || ($context['employee']['status'] ?? '') !== 'active') { + throw new RuntimeException('当前统一账号未获得甄养堂访问权限'); + } + } + + public function resolve(IamOidcClient $client, array $config, string $subject): int + { + $context = $client->provisioning($subject); + self::validateContext($context, $subject, $config['application_id']); + if (($context['shouldCreateLocalAccount'] ?? null) !== true) { + return IamLoginService::boundAdminId($context, $subject, $config['application_id']); + } + if (($context['externalAccountId'] ?? null) !== '') { + throw new RuntimeException('统一账号绑定状态不一致'); + } + $id = $this->createOrResume(self::identity($config, $subject), $context); + // Creation is durable before the remote call. On timeout retry this SAME account, never another one. + $client->bind($subject, $id); + $confirmed = IamLoginService::boundAdminId($client->provisioning($subject), $subject, $config['application_id']); + if ($confirmed !== $id) { + throw new RuntimeException('统一账号绑定冲突,请联系管理员'); + } + // Match AdminLogic::add's best-effort doctor IM initialization only after central confirmation. + // importDoctorAccountToIm catches/logs provider failures; it does not grant permissions. + ($this->imImport)($id, (string) Db::name('admin')->where('id', $id)->value('name')); + return $id; + } + + public function createOrResume(string $identity, array $context): int + { + $key = hash('sha256', $identity); + try { + return Db::transaction(function () use ($key, $identity, $context): int { + $row = Db::name('iam_local_identity')->where('identity_key', $key)->lock(true)->find(); + if ($row) { + return $this->existing($row, $identity); + } + // Unique PK serializes concurrent workers, including workers on different servers. + Db::name('iam_local_identity')->insert(['identity_key' => $key, 'identity_value' => $identity, + 'admin_id' => null, 'create_time' => time()]); + $role = Db::name('system_role')->where('id', 2)->whereNull('delete_time')->lock(true)->find(); + if (!$role || ($role['name'] ?? '') !== '医助' || (isset($role['disable']) && (int) $role['disable'] !== 0)) { + throw new RuntimeException('默认医助角色未就绪,请联系管理员'); + } + // Random local login name; never adopt an existing similarly named local account. + $account = 'iam_' . bin2hex(random_bytes(12)); + if (Db::name('admin')->where('account', $account)->find()) { + throw new RuntimeException('新账号标识冲突,请重新登录'); + } + $name = (string) ($context['employee']['displayName'] ?? '统一账号用户'); + $id = (int) Db::name('admin')->insertGetId([ + 'account' => $account, 'name' => mb_substr($name !== '' ? $name : '统一账号用户', 0, 20), + 'password' => create_password(bin2hex(random_bytes(32)), Config::get('project.unique_identification')), + 'avatar' => (string) Config::get('project.default_image.admin_avatar', ''), + 'root' => 0, 'disable' => 0, 'is_paw' => 0, 'multipoint_login' => 0, + 'gender' => 1, 'enable_image_consult' => 1, 'enable_video_consult' => 1, 'enable_charge' => 0, + 'create_time' => time(), 'update_time' => time(), + ]); + Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => 2]); + Db::name('iam_local_identity')->where('identity_key', $key)->update(['admin_id' => $id]); + return $id; + }); + } catch (\Throwable $error) { + // A concurrent insert may have won while our transaction rolled back. Only the exact ledger is reusable. + $row = Db::name('iam_local_identity')->where('identity_key', $key)->find(); + if ($row) { + return $this->existing($row, $identity); + } + throw $error; + } + } + + private function existing(array $row, string $identity): int + { + if (!hash_equals((string) $row['identity_value'], $identity) || (int) $row['admin_id'] <= 0) { + throw new RuntimeException('统一账号本地开户状态异常'); + } + $admin = Db::name('admin')->where('id', (int) $row['admin_id'])->find(); + if (!$admin || (int) $admin['disable'] !== 0 || !empty($admin['delete_time'])) { + throw new RuntimeException('甄养堂账号已停用,请联系管理员'); + } + // Existing permissions and password/WeCom requirements are never rewritten. + return (int) $row['admin_id']; + } +} diff --git a/server/app/adminapi/service/iam/IamLoginService.php b/server/app/adminapi/service/iam/IamLoginService.php index 2227ed319..cccc3c35f 100644 --- a/server/app/adminapi/service/iam/IamLoginService.php +++ b/server/app/adminapi/service/iam/IamLoginService.php @@ -101,8 +101,7 @@ final class IamLoginService private function verifiedAdmin(string $subject): Admin { - $context = $this->client->provisioning($subject); - $id = self::boundAdminId($context, $subject, $this->config['application_id']); + $id = (new IamAccountProvisioner())->resolve($this->client, $this->config, $subject); $admin = Admin::findOrEmpty($id); if ($admin->isEmpty() || (int) $admin->disable !== 0 || !empty($admin->getData('delete_time'))) { throw new RuntimeException('甄养堂账号已停用,请联系管理员'); diff --git a/server/app/adminapi/service/iam/IamOidcClient.php b/server/app/adminapi/service/iam/IamOidcClient.php index d9e699629..80431b7a2 100644 --- a/server/app/adminapi/service/iam/IamOidcClient.php +++ b/server/app/adminapi/service/iam/IamOidcClient.php @@ -115,6 +115,21 @@ final class IamOidcClient ['Authorization: Bearer ' . $this->config['application_token']]); } + public function bind(string $subject, int $adminId): array + { + if ($subject === '' || strlen($subject) > 512 || $adminId <= 0) { + throw new RuntimeException('IAM binding input is invalid'); + } + $key = hash('sha256', json_encode([$this->config['issuer'], $this->config['application_id'], $subject, $adminId], JSON_THROW_ON_ERROR)); + return $this->request('POST', rtrim($this->config['api_url'], '/') . '/internal/v1/applications/' + . rawurlencode($this->config['application_id']) . '/bindings', [ + 'Authorization: Bearer ' . $this->config['application_token'], + 'Content-Type: application/json', 'X-Request-ID: ' . bin2hex(random_bytes(16)), + 'Idempotency-Key: zyt-provision-' . $key, + ], json_encode(['employeeSubject' => $subject, 'externalAccountId' => (string) $adminId, + 'verificationMethod' => 'oidc_verified_subject_local_creation'], JSON_THROW_ON_ERROR), [201]); + } + private function metadata(): array { if ($this->discovery !== null) { @@ -160,7 +175,7 @@ final class IamOidcClient } } - private function request(string $method, string $url, array $headers = [], string $body = ''): array + private function request(string $method, string $url, array $headers = [], string $body = '', array $statuses = [200]): array { $this->endpoint($url); $headers[] = 'Accept: application/json'; @@ -189,7 +204,7 @@ final class IamOidcClient $result = ['status' => curl_getinfo($curl, CURLINFO_RESPONSE_CODE), 'body' => $ok === false ? false : $response]; curl_close($curl); } - if (!is_array($result) || ($result['status'] ?? 0) !== 200 || !is_string($result['body'] ?? null) || strlen($result['body']) > 1048576) { + if (!is_array($result) || !in_array($result['status'] ?? 0, $statuses, true) || !is_string($result['body'] ?? null) || strlen($result['body']) > 1048576) { throw new RuntimeException('IAM request failed'); } try { diff --git a/server/database/migrations/20260910_iam_local_identity.sql b/server/database/migrations/20260910_iam_local_identity.sql new file mode 100644 index 000000000..7d32d381a --- /dev/null +++ b/server/database/migrations/20260910_iam_local_identity.sql @@ -0,0 +1,10 @@ +-- Apply once before enabling the new adapter. Replace zyt_ only if database.prefix differs. +-- Do not drop this ledger while IAM-created accounts exist: it is retry/deduplication state. +CREATE TABLE IF NOT EXISTS `zyt_iam_local_identity` ( + `identity_key` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `identity_value` text NOT NULL, + `admin_id` int unsigned DEFAULT NULL, + `create_time` int unsigned NOT NULL, + PRIMARY KEY (`identity_key`), + UNIQUE KEY `uniq_iam_admin` (`admin_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/tests/IamProvisioningTest.php b/server/tests/IamProvisioningTest.php new file mode 100644 index 000000000..4b61bcba5 --- /dev/null +++ b/server/tests/IamProvisioningTest.php @@ -0,0 +1,133 @@ + 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 33316, 'database' => 'iam_fixture', 'username' => 'root', 'password' => 'fixture-only', 'prefix' => '', 'charset' => 'utf8mb4', 'fields_strict' => true] + : ['type' => 'sqlite', 'database' => $file, 'prefix' => '', 'fields_strict' => true]; +$app->config->set(['default' => 'test', 'connections' => ['test' => $connection]], 'database'); +$manager = new think\DbManager(); +$manager->setConfig($app->config->get('database')); +$app->instance('think\Db', $manager); +$app->config->set(['unique_identification' => 'test-only'], 'project'); +if (($argv[1] ?? '') === '--worker') { + for ($attempt = 0; $attempt < 8; $attempt++) { + try { + echo (new IamAccountProvisioner())->createOrResume('concurrent-fixture', ['employee' => ['displayName' => 'Concurrent']]); + @unlink($file); + exit(0); + } catch (Throwable $e) { if ($attempt === 7) { throw $e; } usleep(100000); } + } +} +function schemaProvision(string $sql): void { + if (getenv('IAM_TEST_MYSQL') === '1') { + $sql = str_replace(['identity_key TEXT PRIMARY KEY', 'INTEGER PRIMARY KEY AUTOINCREMENT', 'account TEXT UNIQUE'], ['identity_key VARCHAR(64) PRIMARY KEY', 'INTEGER PRIMARY KEY AUTO_INCREMENT', 'account VARCHAR(32) UNIQUE'], $sql); + } + Db::execute($sql); +} +try { + if (getenv('IAM_TEST_MYSQL') === '1') { + foreach (['iam_local_identity', 'admin_role', 'admin', 'system_role'] as $table) { Db::execute('DROP TABLE IF EXISTS ' . $table); } + } + schemaProvision('CREATE TABLE iam_local_identity (identity_key TEXT PRIMARY KEY, identity_value TEXT, admin_id INTEGER UNIQUE, create_time INTEGER)'); + schemaProvision('CREATE TABLE system_role (id INTEGER PRIMARY KEY, name TEXT, delete_time INTEGER, disable INTEGER DEFAULT 0)'); + schemaProvision('CREATE TABLE admin (id INTEGER PRIMARY KEY AUTOINCREMENT, account TEXT UNIQUE, name TEXT, password TEXT, avatar TEXT, root INTEGER, disable INTEGER, is_paw INTEGER, multipoint_login INTEGER, gender INTEGER, enable_image_consult INTEGER, enable_video_consult INTEGER, enable_charge INTEGER, create_time INTEGER, update_time INTEGER, delete_time INTEGER)'); + schemaProvision('CREATE TABLE admin_role (admin_id INTEGER, role_id INTEGER, UNIQUE(admin_id, role_id))'); + Db::name('system_role')->insert(['id' => 2, 'name' => '医助']); + Db::name('admin')->insert(['id' => 42, 'account' => 'legacy', 'name' => 'Same Name', 'root' => 0, 'disable' => 0]); + Db::name('admin_role')->insert(['admin_id' => 42, 'role_id' => 7]); + $config = ['issuer' => 'https://iam.example.test', 'client_id' => 'zyt', 'client_secret' => 'secret', + 'redirect_uri' => 'https://zyt.example.test/adminapi/iam/callback', 'api_url' => 'https://iam.example.test', + 'application_id' => 'zyt', 'application_token' => 'application-secret']; + $context = ['applicationId' => 'zyt', 'localIdentityKey' => 'new-user', + 'employee' => ['oidcSubject' => 'new-user', 'status' => 'active', 'displayName' => 'Same Name'], + 'externalAccountId' => '', 'shouldCreateLocalAccount' => true]; + $calls = []; $fail = false; $confirmMismatch = false; $status = 200; + $client = new IamOidcClient($config, function ($method, $url, $headers, $body) use (&$context, &$calls, &$fail, &$confirmMismatch, &$status) { + $calls[] = [$method, $url, $headers, $body]; + expectProvision(in_array('Authorization: Bearer application-secret', $headers, true), 'service authentication'); + if ($method === 'POST') { + expectProvision(str_ends_with($url, '/applications/zyt/bindings'), 'binding endpoint'); + expectProvision(count(preg_grep('/^Idempotency-Key: zyt-provision-/', $headers)) === 1, 'idempotency key'); + expectProvision(count(preg_grep('/^X-Request-ID: /', $headers)) === 1, 'request id'); + if ($fail) { return ['status' => 503, 'body' => '{}']; } + $binding = json_decode($body, true); + expectProvision($binding['employeeSubject'] === $context['localIdentityKey'], 'verified subject'); + $context['externalAccountId'] = $confirmMismatch ? '42' : $binding['externalAccountId']; + $context['shouldCreateLocalAccount'] = false; + return ['status' => 201, 'body' => json_encode(['state' => 'verified'])]; + } + return ['status' => $status, 'body' => json_encode($context)]; + }); + $imports = []; + $p = new IamAccountProvisioner(function ($id, $name) use (&$imports) { $imports[] = [$id, $name]; }); + $fail = true; + rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'binding outage'); + expectProvision(Db::name('admin')->count() === 2 && $imports === [], 'durable single pending account before IM'); + $pending = Db::name('iam_local_identity')->select()->toArray()[0]['admin_id']; + $fail = false; + $id = $p->resolve($client, $config, 'new-user'); + expectProvision($imports === [[$id, 'Same Name']], 'IM initialization after confirmation'); + expectProvision($id === (int) $pending && $id !== 42, 'retry same identity and no name merge'); + expectProvision(array_map('intval', Db::name('admin_role')->where('admin_id', $id)->column('role_id')) === [2], 'new default role 2 only'); + $admin = Db::name('admin')->where('id', $id)->find(); + expectProvision((int) $admin['root'] === 0 && (int) $admin['is_paw'] === 0, 'no root or password gate bypass'); + $before = Db::name('admin')->select()->toArray(); + expectProvision($p->resolve($client, $config, 'new-user') === $id && Db::name('admin')->select()->toArray() === $before, 'existing login is read only'); + $context['externalAccountId'] = '42'; + expectProvision($p->resolve($client, $config, 'new-user') === 42, 'explicit legacy binding'); + expectProvision(array_map('intval', Db::name('admin_role')->where('admin_id', 42)->column('role_id')) === [7], 'legacy permissions preserved'); + $status = 403; + rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'revoked grant'); + $status = 200; + $context['employee']['status'] = 'disabled'; + rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'disabled employee'); + $context['employee']['status'] = 'active'; + $context['shouldCreateLocalAccount'] = true; $context['externalAccountId'] = ''; + $confirmMismatch = true; + rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'confirmation wrong ID'); + $context['shouldCreateLocalAccount'] = true; $context['externalAccountId'] = ''; + Db::name('admin')->where('id', $id)->update(['disable' => 1]); + rejectProvision(fn() => $p->resolve($client, $config, 'new-user'), 'disabled pending account'); + expectProvision(Db::name('admin')->count() === 2, 'no duplicates'); + $context['localIdentityKey'] = $context['employee']['oidcSubject'] = 'other-user'; + Db::name('system_role')->where('id', 2)->update(['name' => 'Wrong Role']); + rejectProvision(fn() => $p->resolve($client, $config, 'other-user'), 'wrong role'); + expectProvision(Db::name('iam_local_identity')->count() === 1 && Db::name('admin')->count() === 2, 'failed transaction fully rolled back'); + if (getenv('IAM_TEST_MYSQL') === '1') { + Db::name('system_role')->where('id', 2)->update(['name' => '医助']); + $workers = []; + for ($i = 0; $i < 8; $i++) { + $process = proc_open([PHP_BINARY, __FILE__, '--worker'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $workers[] = [$process, $pipes]; + } + $ids = []; + foreach ($workers as [$process, $pipes]) { + $output = stream_get_contents($pipes[1]); $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + expectProvision(proc_close($process) === 0, 'concurrent worker: ' . $error); + $ids[] = (int) $output; + } + expectProvision(count(array_unique($ids)) === 1 && $ids[0] > 0 && Db::name('admin')->count() === 3, 'eight workers one account'); + expectProvision(Db::name('admin_role')->where('admin_id', $ids[0])->count() === 1, 'one role association'); + echo "IamProvisioningMySQLConcurrency passed (8 workers, one identity/account/role, transaction retries)\n"; + } + echo "IamProvisioningTest passed (database transactions, role2, grant denial, no name merge, durable retry, binding confirmation, existing-role preservation, disabled account, rollback)\n"; +} finally { + @unlink($file); +}