- 18 张公共表更名(addon/attachment/configure/links/spider_log/spider_stat/sms/notice/ad/task/prop/medal/help/card -> common_*,member_wallets->member_wallet,score_rule/score_log -> member_*,addon_config->common_addonconf),模型全部对齐新表名,Db 直引用清零 - Attachment 模型补 表名绑定,修复富文本上传查 wxapp_attachment 1146 隐患 - install.sql + 迁移 SQL:backend_admin/backend_role delete_at 默认 0,修复软删除(NULL != 0)误过滤导致后台菜单为空 - AppService::boot() 支持插件 info.php 声明 commands 自动注册插件命令(psr-4 自动加载,坏类名自动跳过) - 各插件(haonav/mqttbroker/wxchat/articles/blog/forum 等)字段与配置同步调整 - 框架版本 1.1.0 -> 1.1.1
265 lines
10 KiB
PHP
265 lines
10 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");
|
||
// 自愈:老表补 member_id 列(框架会员直通用)
|
||
$prefix = Db::getConfig('prefix');
|
||
$has = Db::query("SHOW COLUMNS FROM `{$prefix}mqttbroker_auth` LIKE 'member_id'");
|
||
if (empty($has)) {
|
||
Db::execute("ALTER TABLE `{$prefix}mqttbroker_auth` ADD `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属框架会员ID(会员直通回填)'");
|
||
}
|
||
} 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) {
|
||
// 无此账号:尝试框架会员直通(仅当显式开启且禁止匿名,避免被匿名语义绕过)
|
||
if (!$anonymous && !empty($this->config['allow_member_login'])) {
|
||
$member = null;
|
||
try {
|
||
$member = Db::name('member_user')
|
||
->where('account', $username)
|
||
->where('status', 1)
|
||
->field('id,password')
|
||
->find();
|
||
} catch (\Throwable $e) {
|
||
$member = null;
|
||
}
|
||
// 会员密码使用 bcrypt 原生盐(详见 ywxapp\model\MemberUser),
|
||
// 表无独立 salt 列,直接 verify 即可。
|
||
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['id']];
|
||
}
|
||
}
|
||
// 匿名开时视为普通匿名用户(用户名仅作标识),否则拒绝
|
||
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 === '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 $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);
|
||
}
|
||
}
|