Skip to content

Commit 2b298f9

Browse files
committed
安全验证功能
1 parent 87d36fc commit 2b298f9

17 files changed

Lines changed: 1335 additions & 22 deletions

File tree

app/Language/modules/safety_settings.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,70 @@
3838
"name": "设置/修改密码",
3939
"description": "设置或修改安全验证密码",
4040
},
41+
"password_rules": {
42+
"name": "密码要求",
43+
"description": "长度≥8,且至少包含字母、数字、特殊字符中的任意两类(推荐三类)",
44+
},
45+
"current_password": {"name": "当前密码"},
46+
"new_password": {"name": "新密码"},
47+
"confirm_password": {"name": "确认新密码"},
48+
"password_strength_title": {"name": "密码强度"},
49+
"strength_weak": {"name": "弱"},
50+
"strength_medium": {"name": "中"},
51+
"strength_strong": {"name": "强"},
52+
"save_button": {"name": "保存"},
53+
"cancel_button": {"name": "取消"},
54+
"error_current_password": {"name": "当前密码不正确"},
55+
"error_mismatch": {"name": "新密码与确认不一致"},
56+
"error_strength_insufficient": {"name": "密码强度不足"},
57+
"success_updated": {"name": "密码已更新"},
58+
"remove_password": {"name": "移除密码", "description": "取消当前安全验证密码"},
59+
"remove_password_confirm_title": {"name": "确认移除密码"},
60+
"remove_password_confirm_content": {"name": "移除密码后将禁用安全开关,是否继续?"},
61+
"remove_password_success": {"name": "已移除密码并关闭安全开关"},
62+
"error_title": {"name": "错误"},
63+
"dialog_yes_text": {"name": "来啦老弟"},
64+
"dialog_cancel_text": {"name": "但是我拒绝"},
4165
"totp_switch": {
4266
"name": "TOTP验证",
4367
"description": "启用后可在安全操作中使用TOTP动态口令",
4468
"switchbutton_name": {"enable": "启用", "disable": "禁用"},
4569
},
4670
"set_totp": {"name": "设置TOTP", "description": "配置TOTP动态口令验证"},
71+
"generate_totp_secret": {"name": "生成密钥"},
72+
"verify_totp_code": {"name": "校验验证码"},
73+
"totp_input_placeholder": {"name": "输入TOTP验证码进行校验"},
74+
"totp_secret_prefix": {"name": "密钥"},
75+
"totp_uri_prefix": {"name": "URI"},
76+
"totp_generated_saved": {"name": "已生成并保存TOTP密钥"},
77+
"totp_generated_error": {"name": "生成TOTP失败"},
78+
"totp_code_valid": {"name": "验证码有效"},
79+
"totp_code_invalid": {"name": "验证码无效"},
80+
"totp_save_success": {"name": "设置已保存"},
81+
"totp_verify_before_save": {"name": "请先校验验证码后再保存"},
82+
"totp_qr_unavailable": {"name": "未能显示二维码,请安装二维码库"},
4783
"usb_switch": {
4884
"name": "U盘验证",
4985
"description": "启用后可在安全操作中使用U盘验证",
5086
"switchbutton_name": {"enable": "启用", "disable": "禁用"},
5187
},
5288
"bind_usb": {"name": "绑定U盘", "description": "绑定用于验证的U盘设备"},
5389
"unbind_usb": {"name": "解绑U盘", "description": "解除U盘设备绑定"},
90+
"usb_refresh": {"name": "刷新"},
91+
"usb_bind": {"name": "绑定"},
92+
"usb_unbind_all": {"name": "解绑全部"},
93+
"usb_no_removable": {"name": "未检测到可移动盘"},
94+
"usb_bind_success": {"name": "已绑定 U盘"},
95+
"usb_unbind_all_success": {"name": "已解绑全部 U盘"},
96+
"error_set_password_first": {"name": "请先设置密码"},
97+
"error_set_totp_first": {"name": "请先设置TOTP"},
98+
"error_bind_usb_first": {"name": "请先绑定U盘"},
99+
"usb_unbind_selected": {"name": "解绑选中"},
100+
"usb_unbind_selected_success": {"name": "已解绑选中 U盘"},
101+
"usb_select_bound_hint": {"name": "请选择一个已绑定设备"},
102+
"usb_bound_devices": {"name": "已绑定设备"},
103+
"usb_status_connected": {"name": "U盘已连接"},
104+
"usb_status_disconnected": {"name": "U盘未连接"},
54105
"show_hide_floating_window_switch": {
55106
"name": "显示/隐藏浮窗验证",
56107
"description": "启用后显示或隐藏浮窗时需要安全验证",

app/common/safety/password.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import json
2+
import os
3+
import base64
4+
import hmac
5+
import hashlib
6+
7+
from app.tools.path_utils import get_config_path, get_settings_path, ensure_dir, open_file, file_exists
8+
9+
def _secrets_path():
10+
return get_settings_path("secrets.json")
11+
12+
def _legacy_path():
13+
return get_config_path("security", "secrets.json")
14+
15+
def _read():
16+
p = _secrets_path()
17+
if file_exists(p):
18+
with open_file(p, "r", encoding="utf-8") as f:
19+
return json.load(f)
20+
lp = _legacy_path()
21+
if file_exists(lp):
22+
with open_file(lp, "r", encoding="utf-8") as f:
23+
return json.load(f)
24+
return {}
25+
26+
def _write(d):
27+
ensure_dir(_secrets_path().parent)
28+
with open_file(_secrets_path(), "w", encoding="utf-8") as f:
29+
json.dump(d, f, ensure_ascii=False, indent=4)
30+
31+
def is_configured():
32+
d = _read()
33+
rec = d.get("password")
34+
return isinstance(rec, dict) and bool(rec.get("hash")) and bool(rec.get("salt"))
35+
36+
def set_password(plain: str):
37+
salt = os.urandom(16)
38+
iterations = 200000
39+
dk = hashlib.pbkdf2_hmac("sha256", plain.encode("utf-8"), salt, iterations)
40+
rec = {
41+
"algorithm": "pbkdf2_sha256",
42+
"iterations": iterations,
43+
"salt": base64.b64encode(salt).decode("ascii"),
44+
"hash": base64.b64encode(dk).decode("ascii"),
45+
}
46+
d = _read()
47+
d["password"] = rec
48+
_write(d)
49+
50+
def verify_password(plain: str) -> bool:
51+
d = _read()
52+
rec = d.get("password")
53+
if not rec:
54+
return False
55+
try:
56+
salt = base64.b64decode(rec.get("salt", ""))
57+
iterations = int(rec.get("iterations", 200000))
58+
expected = base64.b64decode(rec.get("hash", ""))
59+
except Exception:
60+
return False
61+
dk = hashlib.pbkdf2_hmac("sha256", plain.encode("utf-8"), salt, iterations)
62+
return hmac.compare_digest(dk, expected)
63+
64+
def clear_password():
65+
d = _read()
66+
if "password" in d:
67+
del d["password"]
68+
_write(d)

app/common/safety/totp.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import json
2+
import pyotp
3+
4+
from app.tools.path_utils import get_config_path, get_settings_path, ensure_dir, open_file, file_exists
5+
6+
def _secrets_path():
7+
return get_settings_path("secrets.json")
8+
9+
def _legacy_path():
10+
return get_config_path("security", "secrets.json")
11+
12+
def _read():
13+
p = _secrets_path()
14+
if file_exists(p):
15+
with open_file(p, "r", encoding="utf-8") as f:
16+
return json.load(f)
17+
lp = _legacy_path()
18+
if file_exists(lp):
19+
with open_file(lp, "r", encoding="utf-8") as f:
20+
return json.load(f)
21+
return {}
22+
23+
def _write(d):
24+
ensure_dir(_secrets_path().parent)
25+
with open_file(_secrets_path(), "w", encoding="utf-8") as f:
26+
json.dump(d, f, ensure_ascii=False, indent=4)
27+
28+
def is_configured():
29+
d = _read()
30+
rec = d.get("totp")
31+
return isinstance(rec, dict) and bool(rec.get("secret"))
32+
33+
def generate_secret():
34+
return pyotp.random_base32()
35+
36+
def set_totp(secret: str | None, issuer: str = "SecRandom", account: str = "user") -> str:
37+
if not secret:
38+
secret = generate_secret()
39+
d = _read()
40+
d["totp"] = {"secret": secret, "issuer": issuer, "account": account}
41+
_write(d)
42+
totp = pyotp.TOTP(secret)
43+
return totp.provisioning_uri(name=account, issuer_name=issuer)
44+
45+
def verify(code: str, window: int = 1) -> bool:
46+
d = _read()
47+
rec = d.get("totp")
48+
if not rec:
49+
return False
50+
secret = rec.get("secret")
51+
if not secret:
52+
return False
53+
totp = pyotp.TOTP(secret)
54+
try:
55+
return bool(totp.verify(code, valid_window=window))
56+
except Exception:
57+
return False

app/common/safety/usb.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import json
2+
import string
3+
import ctypes
4+
from pathlib import Path
5+
import os
6+
import platform
7+
8+
from app.tools.path_utils import get_config_path, get_settings_path, ensure_dir, open_file, file_exists
9+
10+
def _secrets_path():
11+
return get_settings_path("secrets.json")
12+
13+
def _legacy_path():
14+
return get_config_path("security", "secrets.json")
15+
16+
def _read():
17+
p = _secrets_path()
18+
if file_exists(p):
19+
with open_file(p, "r", encoding="utf-8") as f:
20+
return json.load(f)
21+
lp = _legacy_path()
22+
if file_exists(lp):
23+
with open_file(lp, "r", encoding="utf-8") as f:
24+
return json.load(f)
25+
return {}
26+
27+
def _write(d):
28+
ensure_dir(_secrets_path().parent)
29+
with open_file(_secrets_path(), "w", encoding="utf-8") as f:
30+
json.dump(d, f, ensure_ascii=False, indent=4)
31+
32+
def list_removable_drives():
33+
if platform.system() == "Windows":
34+
letters = []
35+
GetDriveTypeW = ctypes.windll.kernel32.GetDriveTypeW
36+
for ch in string.ascii_uppercase:
37+
root = f"{ch}:\\"
38+
p = Path(root)
39+
if p.exists():
40+
t = GetDriveTypeW(root)
41+
if t == 2:
42+
letters.append(ch)
43+
return letters
44+
return []
45+
46+
def get_volume_serial(letter: str) -> str:
47+
buf_name = ctypes.create_unicode_buffer(256)
48+
vol_serial = ctypes.c_uint()
49+
max_comp_len = ctypes.c_uint()
50+
fs_flags = ctypes.c_uint()
51+
fs_name = ctypes.create_unicode_buffer(256)
52+
ctypes.windll.kernel32.GetVolumeInformationW(
53+
f"{letter}:\\",
54+
buf_name,
55+
ctypes.sizeof(buf_name),
56+
ctypes.byref(vol_serial),
57+
ctypes.byref(max_comp_len),
58+
ctypes.byref(fs_flags),
59+
fs_name,
60+
ctypes.sizeof(fs_name),
61+
)
62+
return f"{vol_serial.value:08X}"
63+
64+
def bind(volume_serial: str):
65+
d = _read()
66+
rec = d.get("usb") or {}
67+
arr = rec.get("volume_serials") or []
68+
if volume_serial not in arr:
69+
arr.append(volume_serial)
70+
rec["volume_serials"] = arr
71+
d["usb"] = rec
72+
_write(d)
73+
74+
def unbind(volume_serial: str | None = None):
75+
d = _read()
76+
rec = d.get("usb") or {}
77+
arr = rec.get("volume_serials") or []
78+
if volume_serial is None:
79+
arr = []
80+
else:
81+
arr = [s for s in arr if s != volume_serial]
82+
rec["volume_serials"] = arr
83+
d["usb"] = rec
84+
_write(d)
85+
86+
def is_bound_connected() -> bool:
87+
d = _read()
88+
rec = d.get("usb") or {}
89+
arr = rec.get("volume_serials") or []
90+
if not arr:
91+
return False
92+
if platform.system() == "Windows":
93+
for lt in list_removable_drives():
94+
try:
95+
if get_volume_serial(lt) in arr:
96+
return True
97+
except Exception:
98+
pass
99+
return False
100+
ids = _linux_usb_ids()
101+
for s in arr:
102+
if s in ids:
103+
return True
104+
return False
105+
106+
def get_bound_serials() -> list:
107+
d = _read()
108+
rec = d.get("usb") or {}
109+
arr = rec.get("volume_serials") or []
110+
return list(arr)
111+
112+
def is_serial_connected(serial: str) -> bool:
113+
if platform.system() == "Windows":
114+
try:
115+
for lt in list_removable_drives():
116+
try:
117+
if get_volume_serial(lt) == serial:
118+
return True
119+
except Exception:
120+
pass
121+
except Exception:
122+
pass
123+
return False
124+
try:
125+
return serial in _linux_usb_ids()
126+
except Exception:
127+
return False
128+
129+
def _linux_usb_ids() -> set:
130+
ids = set()
131+
base = "/dev/disk/by-id"
132+
try:
133+
if os.path.isdir(base):
134+
for name in os.listdir(base):
135+
if name.startswith("usb-"):
136+
ids.add(name)
137+
except Exception:
138+
pass
139+
return ids

app/common/safety/verify_ops.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from app.tools.settings_access import readme_settings_async
2+
from app.common.safety.password import is_configured as password_is_configured
3+
from app.page_building.security_window import create_verify_password_window
4+
5+
6+
def should_require_password(op: str) -> bool:
7+
if not password_is_configured():
8+
return False
9+
if not readme_settings_async("basic_safety_settings", "safety_switch"):
10+
return False
11+
key_map = {
12+
"show_hide_floating_window": "show_hide_floating_window_switch",
13+
"restart": "restart_switch",
14+
"exit": "exit_switch",
15+
}
16+
k = key_map.get(op)
17+
if not k:
18+
return False
19+
return bool(readme_settings_async("basic_safety_settings", k))
20+
21+
22+
def require_and_run(op: str, parent, func):
23+
if not should_require_password(op):
24+
func()
25+
return
26+
create_verify_password_window(on_verified=func)
27+

app/config/security/secrets.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"usb": {
3+
"volume_serials": []
4+
}
5+
}

app/page_building/page_template.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,7 @@ def __init__(self, content_widget_class=None, parent: QFrame = None):
3333
self.content_widget_class = content_widget_class
3434

3535
self.__connectSignalToSlot()
36-
37-
QTimer.singleShot(0, self.create_ui_components)
36+
self.create_ui_components()
3837

3938
def __connectSignalToSlot(self):
4039
qconfig.themeChanged.connect(setTheme)
@@ -75,7 +74,7 @@ def create_ui_components(self):
7574
self.ui_created = True
7675

7776
if self.content_widget_class:
78-
QTimer.singleShot(0, self.create_content)
77+
self.create_content()
7978

8079
def create_content(self):
8180
"""后台创建内容组件,避免堵塞进程"""

0 commit comments

Comments
 (0)