安全(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(市场 / 开发者中心 / 审核 / 分润)
710 lines
34 KiB
PHP
710 lines
34 KiB
PHP
<?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);
|
||
}
|
||
}
|