Files

239 lines
8.5 KiB
PHP
Raw Permalink 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\mqttbroker\service;
use think\facade\Db;
/**
* MQTT 认证与 ACL 服务
*
* 认证:
* - allow_anonymous=1:不强制账号;若客户端提供了用户名,则仍校验其密码(存在账号时)。
* - allow_anonymous=0:必须提供用户名+密码且匹配启用中的账号。
* 密码兼容 bcrypt 哈希与明文(明文仅便于开发,生产请用哈希)。
*
* ACLacl_enabled=1 时生效):
* - 规则表按 sort 升序、id 升序匹配,命中第一条即决定 allow/deny。
* - target_typeall(全部) / user(按用户名) / client(按 clientId)。
* - topic 支持 MQTT 通配符(+ / #)与占位符 %u(用户名) %c(clientId)。
* - access1 订阅 / 2 发布 / 3 两者。
* - 超级用户(is_superuser=1)跳过 ACL。
* - $SYS/# 始终允许订阅、禁止发布。
* - 无命中时采用默认策略 acl_defaultallow/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);
}
}