feat: 新增离线一机一码授权系统
This commit is contained in:
4
src/auth/__init__.py
Normal file
4
src/auth/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
授权认证模块
|
||||||
|
"""
|
||||||
205
src/auth/keygen_gui.py
Normal file
205
src/auth/keygen_gui.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Mega Water - 离线授权发卡器 (开发者专用)
|
||||||
|
生成绑定特定机器码的 .lic 授权文件
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# 确保 src.auth 在 path 中
|
||||||
|
_current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_project_root = os.path.abspath(os.path.join(_current_dir, "..", ".."))
|
||||||
|
if _project_root not in sys.path:
|
||||||
|
sys.path.insert(0, _project_root)
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||||
|
QPushButton, QFileDialog, QMessageBox, QApplication, QDateEdit
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import Qt, QDate
|
||||||
|
|
||||||
|
from src.auth.license_manager import generate_license
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseKeygenWindow(QWidget):
|
||||||
|
"""授权发卡器主窗口"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("Mega Water - 离线授权发卡器 (开发者专用)")
|
||||||
|
self.setMinimumSize(640, 340)
|
||||||
|
self.move(400, 280)
|
||||||
|
|
||||||
|
self._default_save_path = os.path.join(_project_root, "license.lic")
|
||||||
|
self._setup_ui()
|
||||||
|
|
||||||
|
def _setup_ui(self):
|
||||||
|
# ── 全局字体:无衬线,清晰 ──
|
||||||
|
font_family = "Microsoft YaHei" if sys.platform == "win32" else "Segoe UI"
|
||||||
|
base_font = f"'{font_family}', 'Segoe UI', sans-serif"
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
* {{
|
||||||
|
font-family: {font_family}, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 11pt;
|
||||||
|
}}
|
||||||
|
QLabel#titleLabel {{
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
}}
|
||||||
|
QLabel#tipLabel {{
|
||||||
|
font-size: 10pt;
|
||||||
|
color: #95a5a6;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
main_layout = QVBoxLayout()
|
||||||
|
main_layout.setContentsMargins(45, 40, 45, 40)
|
||||||
|
main_layout.setSpacing(18)
|
||||||
|
|
||||||
|
# ── 标题 ──
|
||||||
|
title_label = QLabel("离线授权发卡器 (开发者专用)")
|
||||||
|
title_label.setObjectName("titleLabel")
|
||||||
|
title_label.setAlignment(Qt.AlignCenter)
|
||||||
|
main_layout.addWidget(title_label)
|
||||||
|
|
||||||
|
# ── 机器码输入行 ──
|
||||||
|
mc_layout = QHBoxLayout()
|
||||||
|
mc_layout.setSpacing(12)
|
||||||
|
mc_label = QLabel("机器码:")
|
||||||
|
mc_label.setFixedWidth(90)
|
||||||
|
self.mc_input = QLineEdit()
|
||||||
|
self.mc_input.setPlaceholderText("粘贴用户发来的 32 位机器码")
|
||||||
|
self.mc_input.setMinimumHeight(36)
|
||||||
|
self.mc_input.setMinimumWidth(400)
|
||||||
|
mc_layout.addWidget(mc_label, 0)
|
||||||
|
mc_layout.addWidget(self.mc_input, 1)
|
||||||
|
main_layout.addLayout(mc_layout)
|
||||||
|
|
||||||
|
# ── 到期时间选择行 ──
|
||||||
|
exp_layout = QHBoxLayout()
|
||||||
|
exp_layout.setSpacing(12)
|
||||||
|
exp_label = QLabel("到期时间:")
|
||||||
|
exp_label.setFixedWidth(90)
|
||||||
|
self.exp_edit = QDateEdit()
|
||||||
|
self.exp_edit.setCalendarPopup(True)
|
||||||
|
self.exp_edit.setMinimumHeight(36)
|
||||||
|
self.exp_edit.setMinimumWidth(160)
|
||||||
|
# 默认:当前日期 + 1 年
|
||||||
|
self.exp_edit.setDate(QDate.currentDate().addYears(1))
|
||||||
|
exp_layout.addWidget(exp_label, 0)
|
||||||
|
exp_layout.addWidget(self.exp_edit, 0)
|
||||||
|
exp_layout.addStretch(1)
|
||||||
|
main_layout.addLayout(exp_layout)
|
||||||
|
|
||||||
|
# ── 保存路径行 ──
|
||||||
|
path_layout = QHBoxLayout()
|
||||||
|
path_layout.setSpacing(12)
|
||||||
|
path_label = QLabel("保存路径:")
|
||||||
|
path_label.setFixedWidth(90)
|
||||||
|
self.path_input = QLineEdit()
|
||||||
|
self.path_input.setReadOnly(True)
|
||||||
|
self.path_input.setMinimumHeight(36)
|
||||||
|
self.browse_btn = QPushButton("浏览...")
|
||||||
|
self.browse_btn.setMinimumHeight(36)
|
||||||
|
self.browse_btn.setFixedWidth(80)
|
||||||
|
self.browse_btn.clicked.connect(self._on_browse)
|
||||||
|
path_layout.addWidget(path_label, 0)
|
||||||
|
path_layout.addWidget(self.path_input, 1)
|
||||||
|
path_layout.addWidget(self.browse_btn, 0)
|
||||||
|
main_layout.addLayout(path_layout)
|
||||||
|
|
||||||
|
# ── 弹性空间 ──
|
||||||
|
main_layout.addSpacing(10)
|
||||||
|
|
||||||
|
# ── 生成按钮 ──
|
||||||
|
self.gen_btn = QPushButton("生成授权文件 (.lic)")
|
||||||
|
self.gen_btn.setMinimumHeight(48)
|
||||||
|
self.gen_btn.setStyleSheet("""
|
||||||
|
QPushButton {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #2ecc71;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #1e8449;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
self.gen_btn.clicked.connect(self._on_generate)
|
||||||
|
main_layout.addWidget(self.gen_btn)
|
||||||
|
|
||||||
|
# ── 底部提示 ──
|
||||||
|
tip_label = QLabel("生成后请将 license.lic 文件发给用户,放置到软件安装目录下即可。")
|
||||||
|
tip_label.setObjectName("tipLabel")
|
||||||
|
tip_label.setAlignment(Qt.AlignCenter)
|
||||||
|
main_layout.addWidget(tip_label)
|
||||||
|
|
||||||
|
self.setLayout(main_layout)
|
||||||
|
|
||||||
|
def _on_browse(self):
|
||||||
|
"""打开文件对话框选择保存路径"""
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"选择授权文件保存位置",
|
||||||
|
self._default_save_path,
|
||||||
|
"授权文件 (*.lic)"
|
||||||
|
)
|
||||||
|
if path:
|
||||||
|
if not path.lower().endswith(".lic"):
|
||||||
|
path += ".lic"
|
||||||
|
self.path_input.setText(path)
|
||||||
|
|
||||||
|
def _on_generate(self):
|
||||||
|
"""点击生成按钮,调用授权管理器"""
|
||||||
|
machine_code = self.mc_input.text().strip()
|
||||||
|
if not machine_code:
|
||||||
|
QMessageBox.warning(self, "输入错误", "请输入机器码")
|
||||||
|
return
|
||||||
|
|
||||||
|
expiry_date = self.exp_edit.date().toString("yyyy-MM-dd")
|
||||||
|
output_path = self.path_input.text().strip()
|
||||||
|
|
||||||
|
if not output_path:
|
||||||
|
QMessageBox.warning(self, "输入错误", "请设置保存路径")
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, msg = generate_license(
|
||||||
|
machine_code=machine_code,
|
||||||
|
output_path=output_path,
|
||||||
|
expiry_date=expiry_date
|
||||||
|
)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"生成成功",
|
||||||
|
f"✅ 授权文件已成功生成!\n\n保存路径:\n{output_path}\n\n请将此文件发给用户即可。",
|
||||||
|
QMessageBox.Ok
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self,
|
||||||
|
"生成失败",
|
||||||
|
f"❌ {msg}",
|
||||||
|
QMessageBox.Ok
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# ── 高 DPI 自适应(必须放在 QApplication 实例化之前)──
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||||
|
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("LicenseKeygen")
|
||||||
|
window = LicenseKeygenWindow()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec_())
|
||||||
255
src/auth/license_dialog.py
Normal file
255
src/auth/license_dialog.py
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
LicenseDialog - PyQt5 授权拦截弹窗
|
||||||
|
当授权验证失败时弹出,提示用户导入授权文件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||||
|
QTextEdit, QFileDialog, QMessageBox, QApplication
|
||||||
|
)
|
||||||
|
from PyQt5.QtCore import Qt, QTimer
|
||||||
|
from PyQt5.QtGui import QFont, QIcon, QGuiApplication
|
||||||
|
|
||||||
|
# 导入授权管理器
|
||||||
|
from src.auth.license_manager import get_machine_code, verify_license, get_license_path
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseDialog(QDialog):
|
||||||
|
"""
|
||||||
|
授权验证弹窗
|
||||||
|
- 显示本机机器码(只读文本框)
|
||||||
|
- 提供"一键复制"功能
|
||||||
|
- 提供"导入授权文件"按钮
|
||||||
|
- 导入成功后提示重启
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("授权验证")
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.Dialog |
|
||||||
|
Qt.WindowTitleHint |
|
||||||
|
Qt.WindowCloseButtonHint
|
||||||
|
)
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMinimumWidth(540)
|
||||||
|
|
||||||
|
self._init_ui()
|
||||||
|
self._load_machine_code()
|
||||||
|
|
||||||
|
# 窗口居中
|
||||||
|
QTimer.singleShot(0, self._center_on_screen)
|
||||||
|
|
||||||
|
def _center_on_screen(self):
|
||||||
|
"""将窗口居中到屏幕"""
|
||||||
|
screen = QGuiApplication.primaryScreen()
|
||||||
|
if screen:
|
||||||
|
geo = screen.geometry()
|
||||||
|
self.move(
|
||||||
|
(geo.width() - self.width()) // 2,
|
||||||
|
(geo.height() - self.height()) // 2
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
main_layout = QVBoxLayout(self)
|
||||||
|
main_layout.setContentsMargins(30, 30, 30, 20)
|
||||||
|
main_layout.setSpacing(16)
|
||||||
|
|
||||||
|
# ── 标题区 ──
|
||||||
|
title_font = QFont("Microsoft YaHei", 14, QFont.Bold)
|
||||||
|
title_label = QLabel("本软件需要授权方可运行")
|
||||||
|
title_label.setFont(title_font)
|
||||||
|
title_label.setAlignment(Qt.AlignCenter)
|
||||||
|
title_label.setStyleSheet("color: #2c3e50;")
|
||||||
|
main_layout.addWidget(title_label)
|
||||||
|
|
||||||
|
# ── 说明文字 ──
|
||||||
|
hint_label = QLabel(
|
||||||
|
"请获取授权文件(license.lic)后导入,"
|
||||||
|
"或联系技术支持获取授权。"
|
||||||
|
)
|
||||||
|
hint_label.setAlignment(Qt.AlignCenter)
|
||||||
|
hint_label.setStyleSheet("color: #7f8c8d; font-size: 12px;")
|
||||||
|
main_layout.addWidget(hint_label)
|
||||||
|
|
||||||
|
# ── 机器码标签 ──
|
||||||
|
code_label = QLabel("本机机器码(用于申请授权):")
|
||||||
|
code_label.setStyleSheet("font-weight: bold; color: #34495e;")
|
||||||
|
main_layout.addWidget(code_label)
|
||||||
|
|
||||||
|
# ── 机器码文本框 + 复制按钮 ──
|
||||||
|
code_layout = QHBoxLayout()
|
||||||
|
code_layout.setSpacing(8)
|
||||||
|
|
||||||
|
self.code_edit = QTextEdit()
|
||||||
|
self.code_edit.setReadOnly(True)
|
||||||
|
self.code_edit.setMaximumHeight(72)
|
||||||
|
self.code_edit.setFont(QFont("Consolas", 13))
|
||||||
|
self.code_edit.setStyleSheet(
|
||||||
|
"QTextEdit {"
|
||||||
|
" background-color: #ecf0f1;"
|
||||||
|
" border: 1px solid #bdc3c7;"
|
||||||
|
" border-radius: 4px;"
|
||||||
|
" padding: 8px;"
|
||||||
|
" color: #2c3e50;"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
code_layout.addWidget(self.code_edit, 1)
|
||||||
|
|
||||||
|
copy_btn = QPushButton("复制")
|
||||||
|
copy_btn.setFixedWidth(72)
|
||||||
|
copy_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
copy_btn.setStyleSheet(
|
||||||
|
"QPushButton {"
|
||||||
|
" background-color: #3498db;"
|
||||||
|
" color: white;"
|
||||||
|
" border: none;"
|
||||||
|
" border-radius: 4px;"
|
||||||
|
" padding: 8px 4px;"
|
||||||
|
" font-weight: bold;"
|
||||||
|
"}"
|
||||||
|
"QPushButton:hover { background-color: #2980b9; }"
|
||||||
|
"QPushButton:pressed { background-color: #21618c; }"
|
||||||
|
)
|
||||||
|
copy_btn.clicked.connect(self._copy_code)
|
||||||
|
code_layout.addWidget(copy_btn)
|
||||||
|
|
||||||
|
main_layout.addLayout(code_layout)
|
||||||
|
|
||||||
|
# ── 导入授权文件按钮 ──
|
||||||
|
import_btn = QPushButton("导入授权文件 (.lic)")
|
||||||
|
import_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
import_btn.setStyleSheet(
|
||||||
|
"QPushButton {"
|
||||||
|
" background-color: #27ae60;"
|
||||||
|
" color: white;"
|
||||||
|
" border: none;"
|
||||||
|
" border-radius: 6px;"
|
||||||
|
" padding: 12px;"
|
||||||
|
" font-size: 14px;"
|
||||||
|
" font-weight: bold;"
|
||||||
|
"}"
|
||||||
|
"QPushButton:hover { background-color: #229954; }"
|
||||||
|
"QPushButton:pressed { background-color: #1e8449; }"
|
||||||
|
)
|
||||||
|
import_btn.clicked.connect(self._import_license)
|
||||||
|
main_layout.addWidget(import_btn)
|
||||||
|
|
||||||
|
# ── 提示文字 ──
|
||||||
|
tip_label = QLabel(
|
||||||
|
"导入后软件将自动重启生效。"
|
||||||
|
)
|
||||||
|
tip_label.setAlignment(Qt.AlignCenter)
|
||||||
|
tip_label.setStyleSheet("color: #95a5a6; font-size: 11px;")
|
||||||
|
main_layout.addWidget(tip_label)
|
||||||
|
|
||||||
|
# ── 取消按钮(退出程序)──
|
||||||
|
cancel_btn = QPushButton("退出")
|
||||||
|
cancel_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
cancel_btn.setStyleSheet(
|
||||||
|
"QPushButton {"
|
||||||
|
" background-color: #95a5a6;"
|
||||||
|
" color: white;"
|
||||||
|
" border: none;"
|
||||||
|
" border-radius: 4px;"
|
||||||
|
" padding: 8px 20px;"
|
||||||
|
"}"
|
||||||
|
"QPushButton:hover { background-color: #7f8c8d; }"
|
||||||
|
)
|
||||||
|
cancel_btn.clicked.connect(self._quit_app)
|
||||||
|
main_layout.addWidget(cancel_btn, 0, Qt.AlignRight)
|
||||||
|
|
||||||
|
main_layout.addStretch()
|
||||||
|
|
||||||
|
def _load_machine_code(self):
|
||||||
|
"""读取并显示本机机器码"""
|
||||||
|
try:
|
||||||
|
code = get_machine_code(32)
|
||||||
|
self.code_edit.setPlainText(code)
|
||||||
|
except Exception as e:
|
||||||
|
self.code_edit.setPlainText(f"读取失败: {e}")
|
||||||
|
|
||||||
|
def _copy_code(self):
|
||||||
|
"""复制机器码到剪贴板"""
|
||||||
|
clipboard = QApplication.clipboard()
|
||||||
|
clipboard.setText(self.code_edit.toPlainText().strip())
|
||||||
|
|
||||||
|
# 显示反馈
|
||||||
|
QMessageBox.information(self, "已复制", "机器码已复制到剪贴板。")
|
||||||
|
|
||||||
|
def _import_license(self):
|
||||||
|
"""打开文件选择对话框,导入 .lic 授权文件"""
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"选择授权文件",
|
||||||
|
"",
|
||||||
|
"授权文件 (*.lic);;所有文件 (*.*)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 验证授权文件
|
||||||
|
ok, msg = verify_license(file_path)
|
||||||
|
if not ok:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"授权文件无效",
|
||||||
|
f"验证失败: {msg}\n\n请确认选择了正确的授权文件。"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 复制授权文件到标准路径
|
||||||
|
dest_path = get_license_path()
|
||||||
|
try:
|
||||||
|
dest_dir = os.path.dirname(dest_path)
|
||||||
|
if dest_dir:
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
shutil.copy2(file_path, dest_path)
|
||||||
|
except OSError as e:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self,
|
||||||
|
"保存失败",
|
||||||
|
f"无法保存授权文件: {e}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 成功提示,重启程序
|
||||||
|
reply = QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"导入成功",
|
||||||
|
"授权文件已成功导入。\n\n软件将自动重启以应用授权。"
|
||||||
|
if False else # 占位,维持下面的逻辑
|
||||||
|
"授权文件已成功导入。\n软件将自动重启以应用授权。",
|
||||||
|
QMessageBox.Ok
|
||||||
|
)
|
||||||
|
|
||||||
|
self.accept()
|
||||||
|
self._restart_app()
|
||||||
|
|
||||||
|
def _quit_app(self):
|
||||||
|
"""退出程序"""
|
||||||
|
self.reject()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def _restart_app(self):
|
||||||
|
"""重启程序"""
|
||||||
|
self.close()
|
||||||
|
QApplication.quit()
|
||||||
|
|
||||||
|
# 延迟重启(确保 QApplication 完全退出)
|
||||||
|
import subprocess
|
||||||
|
import sys as _sys
|
||||||
|
executable = _sys.executable
|
||||||
|
if getattr(_sys, 'frozen', False):
|
||||||
|
# PyInstaller 打包环境下
|
||||||
|
subprocess.Popen([executable] + _sys.argv[1:])
|
||||||
|
else:
|
||||||
|
# 开发环境
|
||||||
|
subprocess.Popen([executable, __file__])
|
||||||
322
src/auth/license_manager.py
Normal file
322
src/auth/license_manager.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
License Manager - 离线授权管理模块
|
||||||
|
使用 HMAC-SHA256 + 盐值签名防止篡改
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import uuid
|
||||||
|
import hashlib as _hashlib
|
||||||
|
import subprocess
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第一部分:硬件指纹提取(内嵌 get_machine_code)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def get_cpu_id() -> Optional[str]:
|
||||||
|
"""读取 CPU 序列号(Processor ID)"""
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
result = subprocess.run(
|
||||||
|
["wmic", "cpu", "get", "ProcessorId"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW
|
||||||
|
)
|
||||||
|
cpu_id = result.stdout.strip().split("\n")[-1].strip()
|
||||||
|
if cpu_id:
|
||||||
|
return cpu_id
|
||||||
|
else:
|
||||||
|
with open("/proc/cpuinfo", "r") as f:
|
||||||
|
for line in f:
|
||||||
|
if "Serial" in line or "processor" in line:
|
||||||
|
cpu_id = line.split(":")[-1].strip()
|
||||||
|
if cpu_id:
|
||||||
|
return cpu_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_motherboard_uuid() -> Optional[str]:
|
||||||
|
"""读取主板 UUID(BaseBoard Serial Number)"""
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
result = subprocess.run(
|
||||||
|
["wmic", "baseboard", "get", "SerialNumber"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW
|
||||||
|
)
|
||||||
|
board_uuid = result.stdout.strip().split("\n")[-1].strip()
|
||||||
|
board_uuid = re.sub(r'[^a-zA-Z0-9\-]', '', board_uuid)
|
||||||
|
if board_uuid and board_uuid not in ("To be filled", "None"):
|
||||||
|
return board_uuid
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
["cat", "/sys/class/dmi/id/product_uuid"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_code(code_length: int = 32) -> str:
|
||||||
|
"""
|
||||||
|
生成唯一的机器码(硬件指纹)
|
||||||
|
参数:
|
||||||
|
code_length: 机器码长度,支持 16/24/32/48/64 位,默认 32 位
|
||||||
|
返回:
|
||||||
|
全大写字母+数字的机器码字符串
|
||||||
|
"""
|
||||||
|
cpu_id = get_cpu_id() or ""
|
||||||
|
board_uuid = get_motherboard_uuid() or ""
|
||||||
|
raw_hardware = f"{cpu_id}-{board_uuid}"
|
||||||
|
|
||||||
|
if not raw_hardware.strip("-") or len(raw_hardware) < 8:
|
||||||
|
try:
|
||||||
|
machine_name = uuid.gethostname() or ""
|
||||||
|
mac = ':'.join(re.findall('..', '%012x' % uuid.getnode()))
|
||||||
|
raw_hardware = f"{machine_name}-{mac}"
|
||||||
|
except Exception:
|
||||||
|
raw_hardware = str(uuid.getnode())
|
||||||
|
|
||||||
|
raw_hardware = re.sub(r'[^a-zA-Z0-9]', '', raw_hardware)
|
||||||
|
hash_hex = hashlib.sha256(raw_hardware.encode('utf-8')).hexdigest().upper()
|
||||||
|
hash_hex = hash_hex.replace('O', 'X').replace('L', 'Y').replace('I', 'Z')
|
||||||
|
|
||||||
|
valid_lengths = [16, 24, 32, 48, 64]
|
||||||
|
if code_length not in valid_lengths:
|
||||||
|
code_length = 32
|
||||||
|
|
||||||
|
return hash_hex[:code_length]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第二部分:授权文件格式与签名机制
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# 开发者密钥(硬编码在软件中,用于验证授权文件)
|
||||||
|
# 注意:实际部署时建议对密钥进行简单混淆或从外部文件加载
|
||||||
|
DEVELOPER_SECRET = b"WaterQuality_v1_2025_SecretKey"
|
||||||
|
LICENSE_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_signature(payload_json: str) -> str:
|
||||||
|
"""
|
||||||
|
计算 HMAC-SHA256 签名
|
||||||
|
payload_json: JSON 序列化后的字符串(不含 signature 字段)
|
||||||
|
"""
|
||||||
|
sig = hmac.new(
|
||||||
|
DEVELOPER_SECRET,
|
||||||
|
payload_json.encode('utf-8'),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest().upper()
|
||||||
|
return sig
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_hash(s: str) -> str:
|
||||||
|
"""清洗哈希字符串,避免混淆字符"""
|
||||||
|
return s.replace('O', 'X').replace('L', 'Y').replace('I', 'Z')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第三部分:核心 API
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def get_license_path() -> str:
|
||||||
|
"""获取授权文件的标准存放路径(程序根目录)"""
|
||||||
|
if getattr(sys, 'frozen', False):
|
||||||
|
base_dir = os.path.dirname(sys.executable)
|
||||||
|
else:
|
||||||
|
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
return os.path.join(base_dir, "license.lic")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_license(license_path: Optional[str] = None) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
校验授权文件是否匹配本机硬件指纹。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
license_path: 授权文件路径,默认使用标准路径
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(is_valid, message)
|
||||||
|
- is_valid=True 表示授权有效
|
||||||
|
- is_valid=False 表示授权无效,message 为具体原因
|
||||||
|
"""
|
||||||
|
if license_path is None:
|
||||||
|
license_path = get_license_path()
|
||||||
|
|
||||||
|
# Step 1: 文件是否存在
|
||||||
|
if not os.path.isfile(license_path):
|
||||||
|
return False, "授权文件不存在"
|
||||||
|
|
||||||
|
# Step 2: 读取并解析 JSON
|
||||||
|
try:
|
||||||
|
with open(license_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
lic_data = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
return False, f"授权文件格式错误: {e}"
|
||||||
|
|
||||||
|
# Step 3: 校验版本号
|
||||||
|
version = lic_data.get("version", "")
|
||||||
|
if version != LICENSE_VERSION:
|
||||||
|
return False, f"授权文件版本不匹配 (期望 {LICENSE_VERSION})"
|
||||||
|
|
||||||
|
# Step 4: 校验过期时间
|
||||||
|
expiry_str = lic_data.get("expiry", "")
|
||||||
|
if expiry_str:
|
||||||
|
try:
|
||||||
|
expiry_dt = datetime.strptime(expiry_str, "%Y-%m-%d")
|
||||||
|
if datetime.now() > expiry_dt:
|
||||||
|
return False, "授权已过期"
|
||||||
|
except ValueError:
|
||||||
|
return False, "授权文件日期格式错误"
|
||||||
|
|
||||||
|
# Step 5: 提取 payload(不含 signature)
|
||||||
|
payload_for_verify = {k: v for k, v in lic_data.items() if k != "signature"}
|
||||||
|
payload_json = json.dumps(payload_for_verify, sort_keys=True, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Step 6: 校验签名完整性(防篡改)
|
||||||
|
expected_sig = _compute_signature(payload_json)
|
||||||
|
stored_sig = lic_data.get("signature", "").upper()
|
||||||
|
if not hmac.compare_digest(expected_sig, stored_sig):
|
||||||
|
return False, "授权文件签名校验失败(可能被篡改)"
|
||||||
|
|
||||||
|
# Step 7: 校验机器码绑定
|
||||||
|
bound_machine = lic_data.get("machine_code", "")
|
||||||
|
current_machine = get_machine_code(32)
|
||||||
|
if not hmac.compare_digest(bound_machine, current_machine):
|
||||||
|
return False, "机器码不匹配(授权文件与本机不兼容)"
|
||||||
|
|
||||||
|
return True, "授权验证通过"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_license(
|
||||||
|
machine_code: str,
|
||||||
|
output_path: str,
|
||||||
|
expiry_date: Optional[str] = None,
|
||||||
|
product_name: str = "WaterQualityInversion",
|
||||||
|
max_uses: Optional[int] = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
为指定机器码生成合法的授权文件(供开发者使用)。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
machine_code: 目标机器的机器码(32位)
|
||||||
|
output_path: 授权文件输出路径(含文件名,如 "D:/license.lic")
|
||||||
|
expiry_date: 有效期截止日期,格式 "YYYY-MM-DD",默认永久
|
||||||
|
product_name: 产品名称
|
||||||
|
max_uses: 最大使用次数(可选,默认不限制)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(success, message)
|
||||||
|
"""
|
||||||
|
if len(machine_code) not in (16, 24, 32, 48, 64):
|
||||||
|
return False, f"机器码长度无效(期望 16/24/32/48/64,实际 {len(machine_code)})"
|
||||||
|
|
||||||
|
# 构建 payload
|
||||||
|
payload = {
|
||||||
|
"version": LICENSE_VERSION,
|
||||||
|
"product": product_name,
|
||||||
|
"machine_code": machine_code.upper(),
|
||||||
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"expiry": expiry_date or "",
|
||||||
|
}
|
||||||
|
if max_uses is not None:
|
||||||
|
payload["max_uses"] = max_uses
|
||||||
|
|
||||||
|
# 计算签名
|
||||||
|
payload_json = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
||||||
|
signature = _compute_signature(payload_json)
|
||||||
|
|
||||||
|
# 完整授权文件内容
|
||||||
|
lic_content = json.dumps({**payload, "signature": signature}, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 写入文件
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(lic_content)
|
||||||
|
return True, f"授权文件已生成: {output_path}"
|
||||||
|
except OSError as e:
|
||||||
|
return False, f"写入授权文件失败: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_machine_info() -> dict:
|
||||||
|
"""获取完整机器信息(调试用)"""
|
||||||
|
return {
|
||||||
|
"cpu_id": get_cpu_id(),
|
||||||
|
"motherboard_uuid": get_motherboard_uuid(),
|
||||||
|
"machine_code_16": get_machine_code(16),
|
||||||
|
"machine_code_32": get_machine_code(32),
|
||||||
|
"license_path": get_license_path(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第四部分:便捷入口(支持直接运行)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="WaterQuality 授权管理工具")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="子命令")
|
||||||
|
|
||||||
|
# 子命令:verify
|
||||||
|
p_verify = subparsers.add_parser("verify", help="验证本机授权")
|
||||||
|
p_verify.add_argument("-f", "--file", default=None, help="授权文件路径")
|
||||||
|
|
||||||
|
# 子命令:gen / generate
|
||||||
|
p_gen = subparsers.add_parser("generate", help="为指定机器码生成授权文件")
|
||||||
|
p_gen.add_argument("-m", "--machine", required=True, help="目标机器的机器码")
|
||||||
|
p_gen.add_argument("-o", "--output", required=True, help="输出文件路径")
|
||||||
|
p_gen.add_argument("-e", "--expiry", default=None, help="有效期截止日期 YYYY-MM-DD")
|
||||||
|
p_gen.add_argument("-n", "--name", default="WaterQualityInversion", help="产品名称")
|
||||||
|
|
||||||
|
# 子命令:info
|
||||||
|
subparsers.add_parser("info", help="显示本机机器信息")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "verify":
|
||||||
|
ok, msg = verify_license(args.file)
|
||||||
|
print(f"[{'OK' if ok else 'FAIL'}] {msg}")
|
||||||
|
|
||||||
|
elif args.command == "generate":
|
||||||
|
ok, msg = generate_license(args.machine, args.output, args.expiry, args.name)
|
||||||
|
print(f"[{'OK' if ok else 'FAIL'}] {msg}")
|
||||||
|
|
||||||
|
elif args.command == "info":
|
||||||
|
info = get_machine_info()
|
||||||
|
print("=" * 50)
|
||||||
|
print("硬件指纹信息")
|
||||||
|
print("=" * 50)
|
||||||
|
for k, v in info.items():
|
||||||
|
print(f" {k}: {v or '(读取失败)'}")
|
||||||
|
print("=" * 50)
|
||||||
|
# 同时演示验证
|
||||||
|
ok, msg = verify_license()
|
||||||
|
print(f"\n授权验证: [{'OK' if ok else 'FAIL'}] {msg}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
@ -2996,15 +2996,40 @@ class WaterQualityGUI(QMainWindow):
|
|||||||
item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) # 原始颜色
|
item.setForeground(QColor(ModernStylesheet.COLORS.get('text_secondary', '#666666'))) # 原始颜色
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主启动逻辑
|
||||||
|
# ============================================================
|
||||||
def main():
|
def main():
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
app = QApplication(sys.argv)
|
import sys
|
||||||
|
|
||||||
# 设置应用信息
|
# 离线授权验证拦截(必须在业务窗口创建前执行)
|
||||||
|
try:
|
||||||
|
from src.auth.license_manager import verify_license
|
||||||
|
from src.auth.license_dialog import LicenseDialog
|
||||||
|
except ImportError:
|
||||||
|
import os
|
||||||
|
_current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_project_root = os.path.abspath(os.path.join(_current_dir, '..', '..'))
|
||||||
|
if _project_root not in sys.path:
|
||||||
|
sys.path.insert(0, _project_root)
|
||||||
|
from src.auth.license_manager import verify_license
|
||||||
|
from src.auth.license_dialog import LicenseDialog
|
||||||
|
|
||||||
|
_is_license_valid, _license_msg = verify_license()
|
||||||
|
if not _is_license_valid:
|
||||||
|
_license_app = QApplication(sys.argv)
|
||||||
|
_license_app.setApplicationName("WaterQuality")
|
||||||
|
_dialog = LicenseDialog()
|
||||||
|
_dialog.exec_()
|
||||||
|
_license_app.quit()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# 授权通过,正常载入主程序
|
||||||
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Mega Water")
|
app.setApplicationName("Mega Water")
|
||||||
app.setOrganizationName("WaterQuality")
|
app.setOrganizationName("WaterQuality")
|
||||||
|
|
||||||
# 创建主窗口
|
|
||||||
window = WaterQualityGUI()
|
window = WaterQualityGUI()
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
@ -3012,7 +3037,8 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
#冻结,只显示1个exe
|
# 必须紧跟在 if __name__ == "__main__": 下面第一行
|
||||||
# multiprocessing.freeze_support()
|
import multiprocessing
|
||||||
|
multiprocessing.freeze_support()
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user