239 lines
8.5 KiB
PHP
239 lines
8.5 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\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);
|
||
}
|
||
}
|