Compare commits

..
+39 -13
View File
@@ -895,16 +895,40 @@ def _nodes_sync_remote():
except Exception as e: except Exception as e:
log(f"节点配置在线更新失败,使用本地配置: {e}") log(f"节点配置在线更新失败,使用本地配置: {e}")
class _AuthError(Exception):
"""订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
def __init__(self, msg, code=0):
super().__init__(msg)
self.code = code
def _b64url(s: str) -> str:
"""SSO 认证查询参数的 URL-safe Base64 编码(末尾 padding 省略)。"""
return base64.urlsafe_b64encode(str(s).encode("utf-8")).decode("ascii").rstrip("=")
def _fetch_nodes_auth(url, user, password, timeout=10): def _fetch_nodes_auth(url, user, password, timeout=10):
"""带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。""" """用 SSO 账号拉取 nodes.json:user/password 按 URL-safe Base64 作为查询参数
拼接在 nodes_url 后(旧版 nginx auth_basic 已废弃);直连,不经过系统代理。
失败抛 _AuthError(服务端拒绝,str 为服务端中文原因)或 urllib 异常(网络问题)。"""
import json import json
import urllib.request import urllib.request
import urllib.error
opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
req = urllib.request.Request(url) sep = "&" if "?" in url else "?"
req.add_header("Authorization", "Basic " + base64.b64encode( req_url = f"{url}{sep}user={_b64url(user)}&password={_b64url(password)}"
f"{user}:{password}".encode()).decode()) try:
with opener.open(req, timeout=timeout) as r: with opener.open(req_url, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8")) return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
# 401/403/429/5xx:服务端 body 是面向用户的中文原因,直接透出
body = ""
try:
body = e.read().decode("utf-8", "replace").strip()
except Exception:
pass
raise _AuthError(body or f"请求被拒绝 (HTTP {e.code})", e.code) from None
def _user_init_local(): def _user_init_local():
@@ -922,7 +946,7 @@ def _user_init_local():
def _user_init_remote(base_url=None): def _user_init_remote(base_url=None):
"""检查并更新 PAC 数据(后台线程): """检查并更新 PAC 数据(后台线程):
直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。 直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
节点配置的拉取走登录框(nodes_url 需 nginx 认证,见 LoginDialog)。 节点配置的拉取走登录框(nodes_url 需 SSO 认证,见 LoginDialog)。
""" """
if _skip_update_check(): if _skip_update_check():
return # 设置跳过:不拉取也不打日志 return # 设置跳过:不拉取也不打日志
@@ -1397,7 +1421,8 @@ class NodeLabel(QLabel):
# ── 节点配置登录框 ───────────────────────────────────── # ── 节点配置登录框 ─────────────────────────────────────
class LoginDialog(QDialog): class LoginDialog(QDialog):
"""节点配置登录框:凭据参与 nodes_url 的 nginx 认证,成功后拉取并加密保存 nodes.json。""" """节点配置登录框:SSO 账号(邮箱+密码,base64url 查询参数)认证 nodes_url,
成功后拉取并加密保存 nodes.json。"""
_done = pyqtSignal(bool, str) _done = pyqtSignal(bool, str)
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -1454,7 +1479,7 @@ class LoginDialog(QDialog):
form = QVBoxLayout() form = QVBoxLayout()
form.setSpacing(6) form.setSpacing(6)
self.ed_user = QLineEdit() self.ed_user = QLineEdit()
self.ed_user.setPlaceholderText("登录名") self.ed_user.setPlaceholderText("SSO 邮箱")
form.addWidget(self.ed_user) form.addWidget(self.ed_user)
self.ed_pass = QLineEdit() self.ed_pass = QLineEdit()
@@ -1509,7 +1534,7 @@ class LoginDialog(QDialog):
threading.Thread(target=self._worker, args=(user, password), daemon=True).start() threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
def _worker(self, user, password): def _worker(self, user, password):
"""后台登录:Basic 认证拉取 nodes.json,校验后加密写入用户数据目录。""" """后台登录:SSO 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
try: try:
import json import json
url = str(_config_val("nodes_url", "") or "").strip() url = str(_config_val("nodes_url", "") or "").strip()
@@ -1523,12 +1548,13 @@ class LoginDialog(QDialog):
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
_USER_DIR.mkdir(parents=True, exist_ok=True) _USER_DIR.mkdir(parents=True, exist_ok=True)
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text)) (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text))
_auth_save(user, password) # 记住凭据,之后每次启动自动同步节点 _auth_save(user, password) # 记住 SSO 凭据,之后每次启动自动同步节点
self._done.emit(True, str(len(data))) self._done.emit(True, str(len(data)))
except _AuthError as e:
# 服务端拒绝:直接展示其中文原因(认证失败/未开通/限流等)
self._done.emit(False, str(e))
except Exception as e: except Exception as e:
msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401 self._done.emit(False, f"登录失败: {e}")
else f"登录失败: {e}")
self._done.emit(False, msg)
def _on_done(self, ok, info): def _on_done(self, ok, info):
self.btn_ok.setEnabled(True) self.btn_ok.setEnabled(True)