// +---------------------------------------------------------------------- namespace addon\mqttbroker\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)。 * * 表结构见 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; 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_mqttbroker_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_mqttbroker_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"); } 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('mqttbroker_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) { // 无此账号:匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝 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); } /** * ACL 校验 * @param int $action self::ACT_SUB | self::ACT_PUB */ public function checkAcl(?string $username, string $clientId, string $topic, int $action, bool $superuser = false): bool { // $SYS 特殊处理:只读 if (strncmp($topic, '$SYS', 4) === 0) { return $action === self::ACT_SUB; } if (!$this->aclEnabled || $superuser) { return true; } $this->loadAclRules(); $username = (string) $username; foreach ($this->aclRules as $r) { $acc = (int) $r['access']; if ($acc !== 3 && $acc !== $action) { continue; } $type = $r['target_type']; if ($type === 'member' && $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 $this->aclDefaultAllow; } /** * 加载 ACL 规则(带 TTL 缓存) */ protected function loadAclRules(): void { $now = time(); if ($now - $this->aclLoadedAt < $this->aclTtl && $this->aclLoadedAt > 0) { return; } try { $this->aclRules = Db::name('mqttbroker_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); } }