feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+709
View File
@@ -0,0 +1,709 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
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);
}
}