// +---------------------------------------------------------------------- namespace addon\iot\service; use think\facade\Db; /** * MQTT 认证与 ACL 服务 * * 认证: * - allow_anonymous=1:不强制账号;若客户端提供了用户名,则仍校验其密码(存在账号时)。 * - allow_anonymous=0:必须提供用户名+密码且匹配启用中的账号。 * 密码兼容 bcrypt 哈希与明文(明文仅便于开发,生产请用哈希)。 * * ACL(acl_enabled=1 时生效): * - 规则表按 sort 升序、id 升序匹配,命中第一条即决定 allow/deny。 * - target_type:all(全部) / user(按用户名) / client(按 clientId)。 * - topic 支持 MQTT 通配符(+ / #)与占位符 %u(用户名) %c(clientId)。 * - access:1 订阅 / 2 发布 / 3 两者。 * - 超级用户(is_superuser=1)跳过 ACL。 * - $SYS/# 始终允许订阅、禁止发布。 * - 无命中时采用默认策略 acl_default(allow/deny)。 * * ⚠️ 私有主题硬归属校验(不受 acl_enabled / acl_default 影响,见 checkPrivateTopic): * /devices/{uuid}/... 与 /notify/{account}/... 属私有资源,按 **业务真源** * (iot_device.member_id / iot_share)裁决,而非 ACL 规则快照: * 设备本体(clientId=username=uuid 且密码已验通过)→ 放行 * 设备主人 → 全权;被共享者 → perm=2 全权 / perm=1 仅订阅只读子主题 * 认证账号 → 需 ACL 规则显式 allow(默认拒绝) * 匿名 / 未绑定会员 / 他人设备 → 一律拒绝 * 原因:出厂默认 acl_enabled=0、acl_default=allow,若把归属交给 ACL, * 会出现「没绑定也能订阅别人设备」的越权(2026-09-11 修复)。 * * 表结构见 install.sql;本类启动时 ensureTables() 自愈建表。 */ class Auth { const ACT_SUB = 1; const ACT_PUB = 2; /** @var array 运行配置 */ protected $config; /** @var bool 是否启用 ACL */ protected $aclEnabled; /** @var bool ACL 默认放行 */ protected $aclDefaultAllow; /** @var array ACL 规则缓存 */ protected $aclRules = []; /** @var int ACL 缓存刷新时间戳 */ protected $aclLoadedAt = 0; /** @var int 缓存有效期(秒) */ protected $aclTtl = 30; /** @var array 归属校验运行时缓存(key => [value, expire_at]),防高频 publish 反复查库 */ protected $aclRuntime = []; /** @var int 归属校验缓存有效期:撤销共享后最多慢这么久生效 */ protected $ownerTtl = 30; /** * @var string 最近一次 checkAcl() 的拒绝原因(放行时为空串) * * 可观测性专用:Broker 把它写进日志,直接回答「为什么订阅/发布被拒」—— * 是身份降级成 anonymous-named?不是设备主人也没共享?还是 ACL 兜底拒绝? * 在此之前日志只有「订阅被 ACL 拒绝」,四种完全不同的故障看起来一模一样。 */ protected $lastDenyReason = ''; /** 共享权限常量(与 ShareService::PERM_VIEW / PERM_CTRL 对齐,避免循环依赖) */ const PERM_VIEW = 1; const PERM_CTRL = 2; public function __construct(array $config) { $this->config = $config; $this->aclEnabled = (string) ($config['acl_enabled'] ?? '0') === '1'; $this->aclDefaultAllow = (string) ($config['acl_default'] ?? 'allow') !== 'deny'; } /** * 建表自愈(未执行 install.sql / 老版本插件也可用) */ public function ensureTables(): void { try { Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_iot_auth` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `username` varchar(191) NOT NULL DEFAULT '', `password` varchar(191) NOT NULL DEFAULT '', `is_superuser` tinyint(1) NOT NULL DEFAULT '0', `status` tinyint(1) NOT NULL DEFAULT '1', `remark` varchar(255) DEFAULT NULL, `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `wxapp_iot_acl` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `target_type` varchar(16) NOT NULL DEFAULT 'all', `target` varchar(191) NOT NULL DEFAULT '', `topic` varchar(255) NOT NULL DEFAULT '', `access` tinyint(1) NOT NULL DEFAULT '3', `allow` tinyint(1) NOT NULL DEFAULT '1', `sort` int NOT NULL DEFAULT '0', `remark` varchar(255) DEFAULT NULL, `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_sort` (`sort`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // prefix 需从连接实例取(connections.mysql.prefix),Db::getConfig('prefix') 顶层取不到 $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `sn` varchar(64) NOT NULL DEFAULT '' COMMENT '设备序列号', `uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备连接用户名', `secret` varchar(191) NOT NULL DEFAULT '' COMMENT '连接密码(bcrypt)', `pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定校验)', `activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)', `title` varchar(128) NOT NULL DEFAULT '' COMMENT '设备名称', `model` varchar(64) NOT NULL DEFAULT '' COMMENT '产品型号', `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '绑定会员ID', `room` varchar(64) NOT NULL DEFAULT '' COMMENT '房间/位置', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0禁用', `bind_at` int DEFAULT NULL, `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_sn` (`sn`), UNIQUE KEY `uk_uuid` (`uuid`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 设备日志表(设备维度留痕) Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_device_log` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid', `topic` varchar(255) NOT NULL DEFAULT '' COMMENT '主题', `payload` longtext COMMENT '消息内容', `direction` varchar(8) NOT NULL DEFAULT 'in' COMMENT 'in=设备上报 out=服务端下发', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_uuid` (`uuid`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 告警记录表 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_alert` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员', `uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '关联设备uuid(可空)', `type` varchar(32) NOT NULL DEFAULT '' COMMENT 'offline/scene/condition/custom', `level` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1提示 2警告 3严重', `title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题', `content` varchar(512) NOT NULL DEFAULT '' COMMENT '内容', `is_read` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0未读 1已读', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_member` (`member_id`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // OTA 固件表 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ota` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `model` varchar(64) NOT NULL DEFAULT '', `version` varchar(32) NOT NULL DEFAULT '', `filename` varchar(128) NOT NULL DEFAULT '', `file_hash` varchar(64) NOT NULL DEFAULT '', `size` int unsigned NOT NULL DEFAULT '0', `url` varchar(255) NOT NULL DEFAULT '', `remark` varchar(255) DEFAULT NULL, `status` tinyint(1) NOT NULL DEFAULT '1', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_model` (`model`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 设备分组表 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_group` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `member_id` int unsigned NOT NULL DEFAULT '0', `title` varchar(64) NOT NULL DEFAULT '', `type` varchar(16) NOT NULL DEFAULT 'room', `sort` int NOT NULL DEFAULT '0', `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_group_device` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `group_id` int unsigned NOT NULL DEFAULT '0', `device_id` int unsigned NOT NULL DEFAULT '0', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_group_device` (`group_id`,`device_id`), KEY `idx_device` (`device_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 场景表 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员(0=后台全局场景)', `title` varchar(64) NOT NULL DEFAULT '' COMMENT '场景名', `icon` varchar(32) NOT NULL DEFAULT '' COMMENT '图标标识', `enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用', `remark` varchar(255) DEFAULT NULL COMMENT '备注', `create_at` int DEFAULT NULL, `update_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_member` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 场景触发器 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_trigger` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `scene_id` int unsigned NOT NULL DEFAULT '0' COMMENT '场景ID', `type` varchar(16) NOT NULL DEFAULT 'device_state' COMMENT 'device_state/schedule/manual', `topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'device_state: 主题过滤器', `condition_json` text COMMENT '条件JSON', `cron` varchar(64) NOT NULL DEFAULT '' COMMENT 'schedule: 5段cron', `enabled` tinyint(1) NOT NULL DEFAULT '1', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 场景动作 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_action` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `scene_id` int unsigned NOT NULL DEFAULT '0' COMMENT '场景ID', `sort` int NOT NULL DEFAULT '0' COMMENT '执行顺序', `type` varchar(16) NOT NULL DEFAULT 'send_cmd' COMMENT 'send_cmd/http/notify', `device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT 'send_cmd: 目标设备', `payload_json` text COMMENT 'send_cmd: 命令payload / http: 请求体', `topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'http: 回调URL / notify: 通知标题', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 场景执行日志 Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_scene_log` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `scene_id` int unsigned NOT NULL DEFAULT '0' COMMENT '场景ID', `trigger_type` varchar(16) NOT NULL DEFAULT '' COMMENT 'device_state/schedule/manual', `matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1命中 0未命中', `message` text COMMENT '命中摘要/失败原因', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_scene` (`scene_id`), KEY `idx_create_at` (`create_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); // 自愈:老表补 member_id 列(框架会员直通用) $has = Db::query("SHOW COLUMNS FROM `{$prefix}iot_auth` LIKE 'member_id'"); if (empty($has)) { Db::execute("ALTER TABLE `{$prefix}iot_auth` ADD `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(会员直通回填)'"); } // 自愈:老库 device 表补 pair_code / activate_token(2.2.6 华为式绑定) $cols = []; foreach (Db::query("SHOW COLUMNS FROM `{$prefix}iot_device`") as $r) { $cols[(string) $r['Field']] = true; } if (!isset($cols['pair_code'])) { Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `pair_code` varchar(16) NOT NULL DEFAULT '' COMMENT '配对码(机身贴纸,绑定校验)' AFTER `secret`"); } if (!isset($cols['activate_token'])) { Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `activate_token` varchar(64) NOT NULL DEFAULT '' COMMENT '入云激活令牌(一次性)' AFTER `pair_code`"); } if (!isset($cols['mac'])) { Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `mac` varchar(32) NOT NULL DEFAULT '' COMMENT '设备MAC(去冒号小写,即插即用注册身份)' AFTER `sn`"); } if (!isset($cols['dev_type'])) { Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `dev_type` varchar(64) NOT NULL DEFAULT '' COMMENT '设备类型(esp32c3_temp等,App据此渲染面板)' AFTER `model`"); } if (!isset($cols['ver'])) { Db::execute("ALTER TABLE `{$prefix}iot_device` ADD `ver` varchar(32) NOT NULL DEFAULT '' COMMENT '固件版本(OTA检测/提示)' AFTER `dev_type`"); } } catch (\Throwable $e) { } } /** * 认证 * @return array{ok:bool, superuser:bool, reason:string} */ public function authenticate(?string $username, ?string $password, string $clientId): array { $anonymous = (string) ($this->config['allow_anonymous'] ?? '1') === '1'; if ($username === null || $username === '') { // 未提供用户名:匿名开则放行,否则拒绝 return $anonymous ? ['ok' => true, 'superuser' => false, 'reason' => 'anonymous'] : ['ok' => false, 'superuser' => false, 'reason' => 'username required']; } // 提供了用户名:查账号 $acc = null; try { $acc = Db::name('iot_auth')->where('username', $username)->find(); } catch (\Throwable $e) { // 表不存在等异常:匿名开则放行 return $anonymous ? ['ok' => true, 'superuser' => false, 'reason' => 'auth-store-unavailable'] : ['ok' => false, 'superuser' => false, 'reason' => 'auth store unavailable']; } if (!$acc) { // 无此账号:尝试框架会员直通(受后台「允许框架会员登录」开关约束) // ⚠️ 刻意不受 allow_anonymous 影响:这是「显式凭证校验」,不是匿名语义。 // 曾经要求「禁止匿名」才尝试,导致默认配置(允许匿名)下会员永远无法以会员身份认证, // 只能降级成 anonymous-named —— 既拿不到 member_id,也绕过了按会员归属的越权校验。 if (!empty($this->config['allow_member_login'])) { $member = null; try { $member = Db::name('member_user') ->where('account', $username) ->where('status', 1) ->field('uid,password') ->find(); } catch (\Throwable $e) { $member = null; } // 会员密码使用 bcrypt 原生盐(详见 ywxapp\model\MemberUser), // 表无独立 salt 列,直接 verify 即可。member_user 主键为 uid。 if ($member && !empty($member['password']) && password_verify((string) $password, $member['password'])) { $isSuper = !empty($this->config['member_is_superuser']); return ['ok' => true, 'superuser' => $isSuper, 'reason' => 'member', 'member_id' => $member['uid']]; } } // 无此账号:尝试 MQTT 一次性凭证(password=ticket,App 端先 HTTP 换取,短时效,无需会员密码参与) // 同样不受 allow_anonymous 影响:App 主路径(换票连)不能因为站点开了匿名而失去会员身份。 $ticketMember = TicketService::verify($username, (string) $password); if ($ticketMember > 0) { return ['ok' => true, 'superuser' => false, 'reason' => 'ticket', 'member_id' => $ticketMember]; } // 匿名开时视为普通匿名用户(用户名仅作标识,**不享有会员/设备身份**),否则拒绝 return $anonymous ? ['ok' => true, 'superuser' => false, 'reason' => 'anonymous-named'] : ['ok' => false, 'superuser' => false, 'reason' => 'account not found']; } if ((int) $acc['status'] !== 1) { return ['ok' => false, 'superuser' => false, 'reason' => 'account disabled']; } if (!$this->verifyPassword((string) $password, (string) $acc['password'])) { return ['ok' => false, 'superuser' => false, 'reason' => 'bad password']; } return ['ok' => true, 'superuser' => (int) $acc['is_superuser'] === 1, 'reason' => 'ok']; } /** * 密码校验:bcrypt/argon 走 password_verify,其余按明文比较 */ protected function verifyPassword(string $input, string $stored): bool { if ($stored === '') { return false; } if (preg_match('/^\$(2y|2a|argon2)/', $stored)) { return password_verify($input, $stored); } return hash_equals($stored, $input); } /** * 最近一次 checkAcl() 的拒绝原因(空串 = 最近一次为放行) */ public function lastDenyReason(): string { return $this->lastDenyReason; } /** * ACL 校验 * * 拒绝时会把人类可读的原因写入 lastDenyReason(),供 Broker 写日志/上层排查。 * * @param int $action self::ACT_SUB | self::ACT_PUB */ public function checkAcl(?string $username, string $clientId, string $topic, int $action, bool $superuser = false, string $authReason = '', int $memberId = 0): bool { $this->lastDenyReason = ''; // $SYS 特殊处理:只读 if (strncmp($topic, '$SYS', 4) === 0) { if ($action !== self::ACT_SUB) { $this->lastDenyReason = '$SYS 只读(禁止发布)'; return false; } return true; } // 设备注册主题:入网必经(发布 system/device/register/{sn}、订阅 .../resp) // ⚠️ 必须排除通配符:原写法 `[^/]+` 连 `#` / `+` 一起放行,等于把「全站设备注册回执 // (含 uuid + secret)」任人订阅 → 只放行精确的 sn(字母数字/-/_,不含通配符)。 if (preg_match('#^system/device/register/[A-Za-z0-9_.\-]{1,64}(/resp)?$#', $topic)) { return true; } if ($superuser) { return true; } // ── 通配符订阅封堵:防 `#` / `+/...` 绕过私有主题归属校验 ── // /devices/... 与 /notify/... 的保护建立在「真实主题」上,而顶层通配符在订阅时刻 // 无法表达归属(不以 /devices/ 开头 → checkPrivateTopic 返回 null → 落到 acl_default 兜底)。 // 一旦兜底为「允许」,订阅 `#` 就等于拿到全站设备数据 → 非超管的通配符订阅, // 只要不以受控命名空间开头(即可能命中私有主题),一律拒绝。 if ($action === self::ACT_SUB && strpbrk($topic, '+#') !== false && strncmp($topic, '/devices/', 9) !== 0 && strncmp($topic, '/notify/', 8) !== 0 ) { $this->lastDenyReason = '通配符订阅封堵:过滤器既不以 /devices/ 也不以 /notify/ 开头(防 # 通配越权)'; return false; } // ── 私有主题(/devices/{uuid}/... 、/notify/{account}/...)硬归属校验 ── // 刻意放在 acl_enabled 之前:**设备归属是业务底线,不该由 ACL 开关决定**。 // 否则出厂默认(acl_enabled=0 或 acl_default=allow)下,任何人/任何未绑定会员 // 都能订阅他人设备的 state 并发布 cmd。 $hard = $this->checkPrivateTopic($username, $clientId, $topic, $action, $authReason, $memberId); if ($hard !== null) { return $hard; } if (!$this->aclEnabled) { return true; } $rule = $this->matchAclRules($username, $clientId, $topic, $action); if ($rule === null) { if (!$this->aclDefaultAllow) { $this->lastDenyReason = 'ACL 无规则命中,且 acl_default=deny(兜底拒绝)'; } return $this->aclDefaultAllow; } if (!$rule) { $this->lastDenyReason = '命中 ACL 规则(deny),topic=' . $topic; } return $rule; } /** * 常规 ACL 规则匹配(sort/id 升序,命中第一条即定论) * @return bool|null null = 无规则命中,由调用方决定兜底 */ protected function matchAclRules(?string $username, string $clientId, string $topic, int $action, bool $userOnly = false): ?bool { $this->loadAclRules(); $username = (string) $username; foreach ($this->aclRules as $r) { $acc = (int) $r['access']; if ($acc !== 3 && $acc !== $action) { continue; } $type = $r['target_type']; // $userOnly:跨设备通配符只认「点名该账号」的规则,防止 target_type=all 的旧规则误放开全站设备 if ($userOnly && $type !== 'user') { continue; } if ($type === 'user' && $r['target'] !== $username) { continue; } if ($type === 'client' && $r['target'] !== $clientId) { continue; } $filter = str_replace(['%u', '%c'], [$username, $clientId], (string) $r['topic']); if ($this->topicMatch($filter, $topic)) { return (int) $r['allow'] === 1; } } return null; } /** * 私有主题归属裁决(业务真源,不看 ACL 快照) * * /devices/{uuid}/{tail} * ① 设备本体 : authReason=ok(iot_auth 账号密码已验)且身份就是该 uuid * ② 设备主人 : iot_device.member_id === 会员 uid → 全权 * ③ 被共享者 : iot_share.perm=2 全权 / perm=1 仅订阅只读子主题 * ④ 认证账号 : 仅当 ACL 规则显式 allow 才放行(默认拒绝) * ⑤ 匿名/未知 : 一律拒绝 * * /notify/{account}/... 仅该账号本人(服务端内部推送不经 ACL) * * @param string $authReason authenticate() 的 reason:ok / member / ticket / anonymous-named / anonymous * @return bool|null null = 非私有主题,交回常规 ACL 流程 */ protected function checkPrivateTopic(?string $username, string $clientId, string $topic, int $action, string $authReason, int $memberId): ?bool { // ── 告警/事件推送主题 ── if (strncmp($topic, '/notify/', 8) === 0) { $account = (string) (explode('/', $topic)[2] ?? ''); if ($account !== '' && $account === (string) $username) { return true; } $this->lastDenyReason = '/notify 主题仅账号本人可访问(topic.account=' . $account . ',当前 user=' . (string) $username . ',身份=' . $authReason . ')'; return false; } // ── 设备私有主题 ── if (strncmp($topic, '/devices/', 9) !== 0) { return null; } $parts = explode('/', $topic); $uuid = (string) ($parts[2] ?? ''); if ($uuid === '') { return false; } $tail = implode('/', array_slice($parts, 3)); // state / status / cmd / ota/report ... // ① 设备本体(clientId=username=uuid,密码已在 authenticate() 中验过) if ($authReason === 'ok' && ($username === $uuid || ($username === '' || $username === null) && $clientId === $uuid)) { return true; } // ②③ 会员(含 ticket 与会员直通):按业务归属判定 $uid = $memberId; if ($uid <= 0 && $authReason === 'member') { $uid = $this->memberIdByAccount((string) $username); } if ($uid > 0) { // 通配符订阅(/devices/+/state 这类跨设备过滤)无法用单一归属表达, // 直接放行等于把全部用户设备暴露给一个人 → 默认拒绝; // 且只认「点名该账号」的规则(userOnly),防止 target_type=all 的历史规则误放开 if (strpbrk($uuid, '+#') !== false) { $rule = $this->matchAclRules($username, $clientId, $topic, $action, true); if ($rule === true) { return true; } $this->lastDenyReason = '跨设备通配符订阅需 ACL 规则点名该账号放行(device=' . $uuid . ',会员 uid=' . $uid . ')'; return false; } return $this->memberCanAccessDevice($uuid, $uid, $tail, $action); } // ④ 认证账号(管理员建的账号):设备主题默认拒绝,仅「ACL 规则显式放行」可通 if ($authReason === 'ok') { $rule = $this->matchAclRules($username, $clientId, $topic, $action); if ($rule === true) { return true; } $this->lastDenyReason = '当前是「认证账号」(iot_auth) 身份,访问设备私有主题必须:' . '①用自己的 username 当设备 uuid 连(username=' . $uuid . '),或 ②加一条 user=' . (string) $username . ' 的 ACL allow 规则'; return false; } // ⑤ 匿名 / 未知身份 $this->lastDenyReason = '当前身份=' . ($authReason !== '' ? $authReason : '(空)') . ',匿名/未认证身份一律不可访问设备私有主题' . ';若你用会员账号登录:请确认后台已开启「允许框架会员登录」且密码正确' . '(密码错或开关未开时会静默降级成本身份,连接仍然显示成功)'; return false; } /** 会员对该设备子主题的访问裁决(主人恒通过;被共享者按 perm 分级) */ protected function memberCanAccessDevice(string $uuid, int $uid, string $tail, int $action): bool { $dev = $this->deviceInfo($uuid); if ($dev === null) { $this->lastDenyReason = '设备 ' . $uuid . ' 在 iot_device 中不存在(同时防 uuid 探测)'; return false; // 设备不存在(同时防 uuid 探测) } if ((int) $dev['member_id'] === $uid) { return true; // 主人:全权 } $perm = $this->sharePerm((int) $dev['id'], $uid); if ($perm >= self::PERM_CTRL) { return true; } if ($perm >= self::PERM_VIEW) { // 只读共享:仅允许订阅「非控制类」子主题(cmd/ota 是控制通道,只读用户连订阅都不给) if ($action === self::ACT_SUB && self::isReadonlyTail($tail)) { return true; } $this->lastDenyReason = '只读共享(perm=1) 仅可订阅 state/status/event/config/ack/ota/report,当前 tail=' . $tail; return false; } $this->lastDenyReason = '当前会员 uid=' . $uid . ' 既不是设备主人(iot_device.member_id=' . (int) $dev['member_id'] . '),也没有 iot_share 共享记录 → 拒绝(请核对「设备绑定」到底绑给了哪个会员)'; return false; } /** 只读共享可见的子主题(不含 cmd / ota) */ protected static function isReadonlyTail(string $tail): bool { return in_array($tail, ['state', 'status', 'event', 'config', 'ack', 'ota/report'], true); } /** 设备 id + 归属会员(带缓存;不存在返回 null) */ protected function deviceInfo(string $uuid): ?array { $key = 'dev:' . $uuid; $hit = $this->runtimeGet($key); if ($hit !== null) { return $hit === false ? null : $hit; } try { $row = Db::name('iot_device')->where('uuid', $uuid)->field('id,member_id')->find(); } catch (\Throwable $e) { $row = null; } $val = $row ? ['id' => (int) $row['id'], 'member_id' => (int) $row['member_id']] : false; $this->runtimePut($key, $val); return $row ? $val : null; } /** 共享权限(带缓存;无共享返回 0) */ protected function sharePerm(int $deviceId, int $uid): int { if ($deviceId <= 0 || $uid <= 0) { return 0; } $key = 'share:' . $deviceId . ':' . $uid; $hit = $this->runtimeGet($key); if ($hit !== null) { return (int) $hit; } $perm = 0; try { $perm = (int) (Db::name('iot_share') ->where('device_id', $deviceId)->where('member_id', $uid)->where('status', 1) ->value('perm') ?? 0); } catch (\Throwable $e) { $perm = 0; } $this->runtimePut($key, $perm); return $perm; } /** 会员账号 → uid(带缓存;不存在返回 0) */ protected function memberIdByAccount(string $account): int { if ($account === '') { return 0; } $key = 'mem:' . $account; $hit = $this->runtimeGet($key); if ($hit !== null) { return (int) $hit; } $uid = 0; try { $uid = (int) (Db::name('member_user')->where('account', $account)->value('uid') ?? 0); } catch (\Throwable $e) { $uid = 0; } $this->runtimePut($key, $uid); return $uid; } /** 归属校验缓存读取 */ protected function runtimeGet(string $key) { $row = $this->aclRuntime[$key] ?? null; if ($row === null) { return null; } if ($row[1] < time()) { unset($this->aclRuntime[$key]); return null; } return $row[0]; } /** 归属校验缓存写入(Broker 是长驻进程,需限长防内存膨胀) */ protected function runtimePut(string $key, $val): void { if (count($this->aclRuntime) > 2000) { $this->aclRuntime = []; } $this->aclRuntime[$key] = [$val, time() + $this->ownerTtl]; } /** * 加载 ACL 规则(带 TTL 缓存) */ protected function loadAclRules(): void { $now = time(); if ($now - $this->aclLoadedAt < $this->aclTtl && $this->aclLoadedAt > 0) { return; } try { $this->aclRules = Db::name('iot_acl')->order('sort', 'asc')->order('id', 'asc')->select()->toArray(); } catch (\Throwable $e) { $this->aclRules = []; } $this->aclLoadedAt = $now; } /** * 主题过滤器匹配(与 Broker 中一致的 + / # 规则) */ protected function topicMatch(string $filter, string $topic): bool { if ($filter === $topic) { return true; } $f = explode('/', $filter); $t = explode('/', $topic); foreach ($f as $i => $seg) { if ($seg === '#') { return true; } if ($seg === '+') { if (!isset($t[$i])) { return false; } continue; } if (!isset($t[$i]) || $seg !== $t[$i]) { return false; } } return count($f) === count($t); } /** * 生成密码哈希(供后台创建账号使用) */ public static function hashPassword(string $plain): string { return password_hash($plain, PASSWORD_DEFAULT); } }