Files
YwxAppThink/addon/iot/service/Auth.php
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

710 lines
34 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}