diff --git a/app/db.py b/app/db.py index db5b192..dd241df 100644 --- a/app/db.py +++ b/app/db.py @@ -247,6 +247,24 @@ def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]: return row_to_dict(row) +def resolve_web_user_for_activation(conn: sqlite3.Connection, act: dict) -> Optional[dict]: + """设备 token → 网页登录用户:优先账户绑定用户,其次发放激活码的用户。""" + account_id = act.get("account_id") + if account_id: + u = conn.execute( + "SELECT * FROM users WHERE account_id=? AND enabled=1", + (account_id,), + ).fetchone() + if u: + return row_to_dict(u) + created_by = act.get("created_by") + if created_by: + u = get_user(conn, int(created_by)) + if u and int(u.get("enabled") or 0) == 1: + return u + return None + + def _trades_where( account_id: Optional[int] = None, machine_id: Optional[int] = None, diff --git a/app/routes.py b/app/routes.py index ab44ef7..6998416 100644 --- a/app/routes.py +++ b/app/routes.py @@ -7,7 +7,11 @@ import time from datetime import datetime, timedelta, timezone from functools import wraps +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + BJT = timezone(timedelta(hours=8)) +SSO_SALT = "gold-monitor-device-web-sso" +SSO_MAX_AGE = 120 # 秒;设备免密登录票据有效期 from flask import ( Flask, @@ -70,6 +74,24 @@ def register_routes(app: Flask) -> None: "is_admin": session.get("role") == "admin", } + def _sso_serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(Config.SECRET_KEY, salt=SSO_SALT) + + def _establish_session(u: dict, account_id=None) -> None: + session.clear() + session["user_id"] = u["id"] + session["username"] = u["username"] + session["role"] = u["role"] + # 设备免密:会话账户以激活绑定账户为准,便于只看本账户复盘 + session["account_id"] = account_id if account_id is not None else u.get("account_id") + session["user"] = u["username"] + + def _safe_next(raw: str) -> str: + nxt = (raw or "").strip() or url_for("records_page") + if not nxt.startswith("/") or nxt.startswith("//"): + return url_for("records_page") + return nxt + @app.get("/login") def login(): if session.get("user_id"): @@ -84,11 +106,7 @@ def register_routes(app: Flask) -> None: if not u: flash("用户名或密码错误", "error") return render_template("login.html"), 401 - session["user_id"] = u["id"] - session["username"] = u["username"] - session["role"] = u["role"] - session["account_id"] = u.get("account_id") - session["user"] = u["username"] # 兼容旧模板 + _establish_session(u) return redirect(request.args.get("next") or url_for("dashboard")) @app.get("/logout") @@ -96,6 +114,46 @@ def register_routes(app: Flask) -> None: session.clear() return redirect(url_for("login")) + @app.get("/auth/device-login") + def device_login(): + """设备 token / 短期票据 → 网页会话(免密进复盘)。""" + nxt = _safe_next(request.args.get("next") or "") + ticket = (request.args.get("ticket") or "").strip() + token = (request.args.get("token") or "").strip() + u = None + account_id = None + try: + if ticket: + data = _sso_serializer().loads(ticket, max_age=SSO_MAX_AGE) + with database.get_db() as conn: + u = database.get_user(conn, int(data["uid"])) + account_id = data.get("aid") + if u and int(u.get("enabled") or 0) != 1: + u = None + elif token: + with database.get_db() as conn: + act = database.auth_token(conn, token) + if not act: + flash("设备未激活或 token 无效", "error") + return redirect(url_for("login")) + u = database.resolve_web_user_for_activation(conn, act) + account_id = act.get("account_id") + else: + flash("缺少登录凭证", "error") + return redirect(url_for("login")) + except SignatureExpired: + flash("登录链接已过期,请从客户端重新打开复盘", "error") + return redirect(url_for("login")) + except (BadSignature, KeyError, TypeError, ValueError): + flash("登录凭证无效", "error") + return redirect(url_for("login")) + + if not u: + flash("该激活账户未绑定网页用户,请管理员在「用户」中创建并绑定账户", "error") + return redirect(url_for("login")) + _establish_session(u, account_id=account_id) + return redirect(nxt) + @app.get("/settings") @login_required def settings_page(): @@ -555,6 +613,40 @@ def register_routes(app: Flask) -> None: ) return jsonify({"ok": True, "server_time": database.now()}) + @app.post("/api/v1/web-sso") + def api_web_sso(): + """用设备 token 换短期免密登录链接(打开复盘用)。""" + token = request.headers.get("X-Device-Token") or "" + data = request.get_json(silent=True) or {} + nxt = _safe_next(data.get("next") or "/records") + with database.get_db() as conn: + act = database.auth_token(conn, token) + if not act: + return jsonify({"ok": False, "error": "未激活"}), 401 + u = database.resolve_web_user_for_activation(conn, act) + if not u: + return jsonify({ + "ok": False, + "error": "该激活账户未绑定网页用户,请管理员在「用户」中创建并绑定账户", + }), 403 + if act.get("machine_id"): + conn.execute( + "UPDATE machines SET last_seen_at=? WHERE id=?", + (database.now(), act["machine_id"]), + ) + ticket = _sso_serializer().dumps({ + "uid": int(u["id"]), + "aid": act.get("account_id"), + "mid": act.get("machine_id"), + }) + path = url_for("device_login", ticket=ticket, next=nxt) + return jsonify({ + "ok": True, + "ticket": ticket, + "path": path, + "expires_in": SSO_MAX_AGE, + }) + @app.post("/api/v1/trades") def api_trades_upsert(): token = request.headers.get("X-Device-Token") or "" diff --git a/app/templates/login.html b/app/templates/login.html index 081e436..3a8c1a4 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -35,7 +35,6 @@ -

默认 admin / admin123(请部署后修改 .env)

diff --git a/desktop/gold_order_app.py b/desktop/gold_order_app.py index 9801093..999a7de 100644 --- a/desktop/gold_order_app.py +++ b/desktop/gold_order_app.py @@ -1847,21 +1847,29 @@ class OrderApp(tk.Tk): threading.Thread(target=run, daemon=True).start() def on_open_review(self) -> None: - """打开中控复盘登录页(浏览器),使用已保存地址。""" - server = "" - try: - server = (load_device().get("server") or "").rstrip("/") - except Exception: - server = "" - if not server: - self.set_status("未激活:请先完成中控激活", ok=False) + """用本机激活 token 免密打开网页复盘。""" + def work() -> None: + try: + if hub_client is None: + raise RuntimeError("未打包中控客户端") + url = hub_client.open_review_url("/records") + self.after(0, lambda: self._open_review_done(url, None)) + except Exception as exc: + self.after(0, lambda: self._open_review_done(None, exc)) + + self.set_status("正在获取复盘免密链接…", ok=True) + threading.Thread(target=work, daemon=True).start() + + def _open_review_done(self, url: Optional[str], exc: Optional[BaseException]) -> None: + if exc is not None or not url: + msg = str(exc) if exc else "无法打开复盘" + self.set_status(msg, ok=False) return - url = server.rstrip("/") + "/login" try: webbrowser.open(url) - self.set_status(f"已打开复盘登录页:{url}", ok=True) - except Exception as exc: - self.set_status(f"无法打开浏览器:{exc}", ok=False) + self.set_status("已免密打开复盘(本机 token)", ok=True) + except Exception as open_exc: + self.set_status(f"无法打开浏览器:{open_exc}", ok=False) def destroy(self) -> None: try: diff --git a/desktop/hub_client.py b/desktop/hub_client.py index e370bc7..1bf5f34 100644 --- a/desktop/hub_client.py +++ b/desktop/hub_client.py @@ -121,3 +121,39 @@ def report_bars(symbol: str, timeframe: str, bars: list[dict]) -> int: return int(r.get("count") or 0) except HubError: return 0 + + +def open_review_url(next_path: str = "/records") -> str: + """用本机设备 token 换免密复盘链接;旧服务端则退回 token 直链。""" + from urllib.parse import quote + + d = load_device() + server = (d.get("server") or "").rstrip("/") + token = (d.get("token") or "").strip() + if not server or not token: + raise HubError("未激活:请先完成中控激活") + nxt = next_path if next_path.startswith("/") else "/records" + direct = f"{server}/auth/device-login?token={quote(token)}&next={quote(nxt)}" + try: + r = _request( + "POST", + server, + "/api/v1/web-sso", + body={"next": nxt}, + token=token, + timeout=10, + ) + if r.get("ok") and r.get("path"): + return server + str(r["path"]) + if r.get("ok") and r.get("ticket"): + return ( + f"{server}/auth/device-login?ticket={quote(str(r['ticket']))}" + f"&next={quote(nxt)}" + ) + raise HubError(r.get("error") or "无法获取免密登录链接") + except HubError as exc: + msg = str(exc) + if "未绑定" in msg or "未激活" in msg: + raise + # 旧服务端无 /api/v1/web-sso 时走 token 直链 + return direct diff --git a/releases/黄金市价辅助.exe b/releases/黄金市价辅助.exe index 3b92ea1..9c6d3e2 100644 Binary files a/releases/黄金市价辅助.exe and b/releases/黄金市价辅助.exe differ