chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
<?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 哈希与明文(明文仅便于开发,生产请用哈希)。
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user