// +---------------------------------------------------------------------- namespace addon\iot\service; use think\facade\Db; /** * MQTT 一次性连接凭证(ticket) * * 背景:App 直接用会员明文密码连 MQTT(allow_member_login)在对外分发场景 * 存在反编译泄露风险;本服务提供「短时效一次性凭证」替代: * - App 登录后调 /iot/api/client/mqttTicket 换取 ticket(默认 60s 有效) * - MQTT 连接时 username=会员账号、password=ticket * - Auth::authenticate() 校验 ticket 表:存在、未过期、账号一致 → 放行并回填 member_id * - 凭证过期自动失效,重连前 App 重新换取;无需会员密码参与 MQTT 认证 * * 表:iot_ticket(自愈建表,见 ensureTable) */ class TicketService { /** 凭证有效期(秒):需覆盖 App 一次连接建立 + 一次短重连 */ const TTL = 60; /** * 自愈建表(幂等) */ public static function ensureTable(): void { try { $prefix = Db::connect()->getConfig('prefix') ?: 'wxapp_'; Db::execute("CREATE TABLE IF NOT EXISTS `{$prefix}iot_ticket` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `ticket` varchar(64) NOT NULL DEFAULT '' COMMENT '一次性凭证(48位hex)', `member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '会员ID', `username` varchar(64) NOT NULL DEFAULT '' COMMENT '会员账号(MQTT连接用户名)', `expire_at` int NOT NULL DEFAULT '0' COMMENT '过期时间戳', `create_at` int DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_ticket` (`ticket`), KEY `idx_expire` (`expire_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); } catch (\Throwable $e) { } } /** * 签发凭证(每次连接前调用,一次一签) * @return string 48 位十六进制凭证 */ public static function issue(int $memberId, string $username, int $ttl = self::TTL): string { self::ensureTable(); self::cleanup(); $ticket = bin2hex(random_bytes(24)); $now = time(); Db::name('iot_ticket')->insert([ 'ticket' => $ticket, 'member_id' => $memberId, 'username' => $username, 'expire_at' => $now + max(10, (int) $ttl), 'create_at' => $now, ]); return $ticket; } /** * 校验凭证:存在、未过期、且与连接用户名绑定一致 * @return int 关联会员ID(0=无效) */ public static function verify(string $username, string $ticket): int { if ($ticket === '' || $username === '') { return 0; } self::ensureTable(); $row = Db::name('iot_ticket')->where('ticket', $ticket)->find(); if (!$row) { return 0; } if ((int) $row['expire_at'] < time()) { return 0; } if ((string) $row['username'] !== $username) { return 0; } return (int) $row['member_id']; } /** * 清理过期凭证(低频,签发时顺手调用) */ public static function cleanup(): void { try { Db::name('iot_ticket')->where('expire_at', '<', time())->delete(); } catch (\Throwable $e) { } } }