Remove login defaults; open review via device token SSO.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -247,6 +247,24 @@ def auth_token(conn: sqlite3.Connection, token: str) -> Optional[dict]:
|
|||||||
return row_to_dict(row)
|
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(
|
def _trades_where(
|
||||||
account_id: Optional[int] = None,
|
account_id: Optional[int] = None,
|
||||||
machine_id: Optional[int] = None,
|
machine_id: Optional[int] = None,
|
||||||
|
|||||||
+97
-5
@@ -7,7 +7,11 @@ import time
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
BJT = timezone(timedelta(hours=8))
|
BJT = timezone(timedelta(hours=8))
|
||||||
|
SSO_SALT = "gold-monitor-device-web-sso"
|
||||||
|
SSO_MAX_AGE = 120 # 秒;设备免密登录票据有效期
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Flask,
|
Flask,
|
||||||
@@ -70,6 +74,24 @@ def register_routes(app: Flask) -> None:
|
|||||||
"is_admin": session.get("role") == "admin",
|
"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")
|
@app.get("/login")
|
||||||
def login():
|
def login():
|
||||||
if session.get("user_id"):
|
if session.get("user_id"):
|
||||||
@@ -84,11 +106,7 @@ def register_routes(app: Flask) -> None:
|
|||||||
if not u:
|
if not u:
|
||||||
flash("用户名或密码错误", "error")
|
flash("用户名或密码错误", "error")
|
||||||
return render_template("login.html"), 401
|
return render_template("login.html"), 401
|
||||||
session["user_id"] = u["id"]
|
_establish_session(u)
|
||||||
session["username"] = u["username"]
|
|
||||||
session["role"] = u["role"]
|
|
||||||
session["account_id"] = u.get("account_id")
|
|
||||||
session["user"] = u["username"] # 兼容旧模板
|
|
||||||
return redirect(request.args.get("next") or url_for("dashboard"))
|
return redirect(request.args.get("next") or url_for("dashboard"))
|
||||||
|
|
||||||
@app.get("/logout")
|
@app.get("/logout")
|
||||||
@@ -96,6 +114,46 @@ def register_routes(app: Flask) -> None:
|
|||||||
session.clear()
|
session.clear()
|
||||||
return redirect(url_for("login"))
|
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")
|
@app.get("/settings")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
@@ -555,6 +613,40 @@ def register_routes(app: Flask) -> None:
|
|||||||
)
|
)
|
||||||
return jsonify({"ok": True, "server_time": database.now()})
|
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")
|
@app.post("/api/v1/trades")
|
||||||
def api_trades_upsert():
|
def api_trades_upsert():
|
||||||
token = request.headers.get("X-Device-Token") or ""
|
token = request.headers.get("X-Device-Token") or ""
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn" style="width:100%" type="submit">登录</button>
|
<button class="btn" style="width:100%" type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted" style="margin-top:14px;text-align:center">默认 admin / admin123(请部署后修改 .env)</p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+20
-12
@@ -1847,21 +1847,29 @@ class OrderApp(tk.Tk):
|
|||||||
threading.Thread(target=run, daemon=True).start()
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
def on_open_review(self) -> None:
|
def on_open_review(self) -> None:
|
||||||
"""打开中控复盘登录页(浏览器),使用已保存地址。"""
|
"""用本机激活 token 免密打开网页复盘。"""
|
||||||
server = ""
|
def work() -> None:
|
||||||
try:
|
try:
|
||||||
server = (load_device().get("server") or "").rstrip("/")
|
if hub_client is None:
|
||||||
except Exception:
|
raise RuntimeError("未打包中控客户端")
|
||||||
server = ""
|
url = hub_client.open_review_url("/records")
|
||||||
if not server:
|
self.after(0, lambda: self._open_review_done(url, None))
|
||||||
self.set_status("未激活:请先完成中控激活", ok=False)
|
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
|
return
|
||||||
url = server.rstrip("/") + "/login"
|
|
||||||
try:
|
try:
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
self.set_status(f"已打开复盘登录页:{url}", ok=True)
|
self.set_status("已免密打开复盘(本机 token)", ok=True)
|
||||||
except Exception as exc:
|
except Exception as open_exc:
|
||||||
self.set_status(f"无法打开浏览器:{exc}", ok=False)
|
self.set_status(f"无法打开浏览器:{open_exc}", ok=False)
|
||||||
|
|
||||||
def destroy(self) -> None:
|
def destroy(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -121,3 +121,39 @@ def report_bars(symbol: str, timeframe: str, bars: list[dict]) -> int:
|
|||||||
return int(r.get("count") or 0)
|
return int(r.get("count") or 0)
|
||||||
except HubError:
|
except HubError:
|
||||||
return 0
|
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
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user