feat(iam): provision authorized first-login users as medical assistants

This commit is contained in:
2026-09-10 12:15:15 +08:00
parent f8b6196205
commit 6325ba88ff
6 changed files with 297 additions and 4 deletions
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service\iam;
use RuntimeException;
use app\adminapi\logic\auth\AdminLogic;
use think\facade\Config;
use think\facade\Db;
/** Local identity ledger survives IAM timeouts; no names/phones/emails are identity proofs. */
final class IamAccountProvisioner
{
private $imImport;
public function __construct(?callable $imImport = null)
{
$this->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'];
}
}
@@ -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('甄养堂账号已停用,请联系管理员');
@@ -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 {