diff --git a/.env.example b/.env.example index b81a684..5444212 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,15 @@ PMS_REDIS_URL=redis://:密码@192.168.16.150:6379/8 # ---- 管理页面 ---- PMS_WEB_PORT=38100 +# ---- 登录会话密钥 (2026-08 加权限) ---- +# PMS 自带登录: 手机号+密码交给第三方 bshop 校验, 通过后 PMS 自签一张会话票 (cookie)。 +# 这一项是**密钥**, 与下面 QMT 两把密钥同规矩: 只在这里维护, 不入库、不进 ParamStore、 +# 不上管理页面、不进代码。开着登录 (PMS_AUTH_ENABLED 默认 true) 却留空时, 服务拒绝放行。 +# 生成一段随机密钥 (任选一条): +# python -c "import secrets; print(secrets.token_urlsafe(48))" +# openssl rand -base64 48 +PMS_SESSION_SECRET= + # ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ---- # 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。 # 生成一对新密钥 (公钥带外交给 QMT 侧, 私钥不出机器): diff --git a/app/web/auth.py b/app/web/auth.py new file mode 100644 index 0000000..c0dfaac --- /dev/null +++ b/app/web/auth.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" +PMS 登录与权限 —— 认证解析 / 角色判定 / 会话票签验 / 接口鉴权判定(纯函数) +======================================================================= +方案见「PMS 加登录与两个角色(交易系统管理员 / 交易员)」。本模块刻意只放纯函数: +不连库、几乎不联网,喂进去字典或字符串就能算,便于单测 +(scripts/test_batch18_units.py)。唯一碰网络的是 call_bshop_login 一个函数, +它把真正的 HTTP 调用隔离出来,其余全部可离线测。 + +三块职责: + 一、认证:解析第三方登录系统 bshop 的登录返回(parse_bshop_login)。 + 二、角色:从 bshop 返回的权限清单里认出交易系统管理员 / 交易员(role_from_permissions)。 + 三、会话与鉴权:自签会话票(make_session / verify_session),以及按「方法 + 路径」 + 判这个请求要什么角色(authz_decision)。 + +会话票格式:base64url( json({phone, username, role, exp}) ) + "." + base64url( hmac_sha256 ) + —— 只用标准库 hmac / hashlib,不引新依赖。exp 是 Unix 秒。 +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import time +from typing import Optional + +# 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。 +ROLE_ADMIN = "交易系统管理员" +ROLE_TRADER = "交易员" + + +# ============================================================ 一、认证:解析 bshop 返回 +def parse_bshop_login(resp_json: dict) -> dict: + """把 bshop 登录返回解析成统一结构(不判角色,角色交给 role_from_permissions)。 + + 入参是已经 json 解析好的字典(真正发 HTTP 的是 call_bshop_login)。 + 成功: {"ok": True, "phone", "username", "permissions": [...], "orgs": [...]} + 失败: {"ok": False, "error": "..."} + permissions 每条归一成 "system_key:menu_key" 字符串,方便后面比对。 + """ + if not isinstance(resp_json, dict): + return {"ok": False, "error": "登录服务返回的不是 JSON 对象"} + if resp_json.get("code") != 200: + return {"ok": False, "error": str(resp_json.get("msg") or "登录失败")} + data = resp_json.get("data") or {} + user = data.get("user") or {} + phone = str(user.get("phone") or "").strip() + username = str(user.get("username") or phone or "").strip() + if not phone: + return {"ok": False, "error": "登录返回里没有手机号"} + perms = [] + for p in (data.get("permissions") or []): + sk = str((p or {}).get("system_key") or "").strip() + mk = str((p or {}).get("menu_key") or "").strip() + if sk: + perms.append(sk + ":" + mk) + orgs = [] + for o in (data.get("orgs") or []): + orgs.append({"id": (o or {}).get("id"), "org_name": (o or {}).get("org_name")}) + return {"ok": True, "phone": phone, "username": username, + "permissions": perms, "orgs": orgs} + + +def call_bshop_login(login_url: str, phone: str, password: str, timeout: int = 10) -> dict: + """真正发 HTTP 调 bshop 登录。**本模块里唯一碰网络的函数。** + 用 multipart/form-data 提交(与对方 curl 一致)。网络或解析失败也回统一的 + {"ok": False, "error"} 结构,绝不抛出去。 + """ + import requests # 局部导入:纯函数单测既不装也不连任何东西 + try: + r = requests.post( + login_url, + files={"phone": (None, str(phone)), "password": (None, str(password))}, + timeout=timeout, + ) + except Exception as e: + return {"ok": False, "error": "连不上登录服务: " + type(e).__name__ + ": " + str(e)} + try: + body = r.json() + except Exception: + return {"ok": False, "error": "登录服务返回非 JSON (HTTP " + str(r.status_code) + ")"} + return parse_bshop_login(body) + + +# ============================================================ 二、角色判定 +def _markers(csv: str) -> set: + """把 "pms:admin, band:yhyqx" 这类逗号分隔配置切成集合。""" + return {t.strip() for t in (csv or "").split(",") if t.strip()} + + +def role_from_permissions(permissions, admin_perms_csv: str, + trader_perms_csv: str = "") -> Optional[str]: + """按权限清单判角色。 + - 命中任一「管理员标记」权限 -> 交易系统管理员 + - 否则若配置了「交易员准入」且一条都不占 -> None(无权进入) + - 否则 -> 交易员 + permissions 是 "system_key:menu_key" 字符串列表(parse_bshop_login 已归一)。 + """ + have = set(permissions or []) + admin = _markers(admin_perms_csv) + trader = _markers(trader_perms_csv) + if admin & have: + return ROLE_ADMIN + if trader and not (trader & have): + return None + return ROLE_TRADER + + +# ============================================================ 三、会话票签验 +def _b64e(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64d(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def sign_session(payload: dict, secret: str) -> str: + """把 {phone, username, role, exp} 签成会话票字符串。""" + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), + sort_keys=True).encode("utf-8") + b = _b64e(body) + sig = hmac.new(secret.encode("utf-8"), b.encode("ascii"), hashlib.sha256).digest() + return b + "." + _b64e(sig) + + +def make_session(phone: str, username: str, role: str, secret: str, + ttl_hours: int, now: Optional[float] = None) -> str: + """签发一张带过期时间的会话票。now 可注入,便于单测。""" + base = time.time() if now is None else now + exp = int(base + int(ttl_hours) * 3600) + return sign_session({"phone": phone, "username": username, "role": role, "exp": exp}, secret) + + +def verify_session(cookie: str, secret: str, now: Optional[float] = None) -> Optional[dict]: + """验会话票。签名不对、过期、格式坏、空票或空密钥一律返回 None;否则返回 payload。""" + if not cookie or not secret: + return None + try: + b, sig = cookie.split(".", 1) + except ValueError: + return None + expect = hmac.new(secret.encode("utf-8"), b.encode("ascii"), hashlib.sha256).digest() + try: + got = _b64d(sig) + except Exception: + return None + if not hmac.compare_digest(expect, got): + return None + try: + payload = json.loads(_b64d(b).decode("utf-8")) + except Exception: + return None + base = time.time() if now is None else now + if int(payload.get("exp") or 0) < base: + return None + return payload + + +# ============================================================ 四、接口鉴权判定 +# 交易员放行的写接口(白名单)。其余任何写接口一律只给管理员 —— 「写操作默认拒绝」。 +# 路径按段匹配,"*" 吃掉一段路径参数(如 {command_id})。 +_TRADER_WRITE = [ + ("POST", ("api", "commands")), # 下命令 + ("POST", ("api", "commands", "*", "cancel")), # 撤命令 + ("POST", ("api", "commands", "*", "replan")), # 改期重排 + ("POST", ("api", "proposals", "*", "decide")), # 拍板提议 +] + +# 只给管理员的读接口(其余 GET 所有登录人可看)。 +_ADMIN_GET = [ + ("GET", ("api", "ops", "rebuild-preflight")), + ("GET", ("api", "ops", "rebuild-accept")), + ("GET", ("api", "ops", "downstream-schema")), +] + + +def _seg(path: str) -> tuple: + return tuple(s for s in (path or "").strip("/").split("/") if s != "") + + +def _match(pattern: tuple, segs: tuple) -> bool: + if len(pattern) != len(segs): + return False + return all(p == "*" or p == s for p, s in zip(pattern, segs)) + + +def authz_decision(method: str, path: str) -> str: + """判这个请求所需的最低角色,返回 "admin"(要交易系统管理员)或 "any"(任何登录人可)。 + 规则:写方法(POST/PUT/PATCH/DELETE)不在交易员白名单里就要管理员; + GET/HEAD 默认放行,仅少数运维读接口要管理员。 + 只对「已登录、且不在公开白名单」的请求调用(公开白名单在 is_public_path)。 + """ + method = (method or "").upper() + segs = _seg(path) + if method in ("GET", "HEAD"): + for m, pat in _ADMIN_GET: + if m == method and _match(pat, segs): + return "admin" + return "any" + for m, pat in _TRADER_WRITE: + if m == method and _match(pat, segs): + return "any" + return "admin" + + +# 公开路径:不需要登录也放行。首页 / 必须放行,否则登录框自己都加载不出来。 +_PUBLIC_EXACT = {"/", "/health", "/favicon.ico", + "/api/auth/login", "/api/auth/logout", "/api/me"} + + +def is_public_path(path: str) -> bool: + p = (path or "/") + if p in _PUBLIC_EXACT or (p.rstrip("/") or "/") in _PUBLIC_EXACT: + return True + if p == "/static" or p.startswith("/static/"): + return True + return False diff --git a/app/web/main.py b/app/web/main.py index 276d9aa..ac8b194 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -17,7 +17,7 @@ import logging import os from datetime import datetime, timedelta -from fastapi import Body, FastAPI, Query +from fastapi import Body, FastAPI, Query, Request from fastapi.responses import FileResponse, JSONResponse from config.settings import settings @@ -26,6 +26,7 @@ from app.core import tradedays as td from app.db import session as dbs from app.repo import downstream_repo, pms_repo from app.services import command_service, industry, ledger_service, param_store, portfolio +from app.web import auth as authmod logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s") @@ -49,6 +50,34 @@ except Exception as _e: # 挂不上不影响任何 API, 页面照常 logger.warning("静态目录挂载失败 (不影响接口): %s", _e) +# ================================================================ 登录闸 (中间件) +@app.middleware("http") +async def _auth_guard(request: Request, call_next): + """除公开白名单外都要有效会话票; 交易员碰管理员接口回 403。 + 关键: 中间件里只 return JSONResponse, 不 raise —— 在这里抛异常会变成 500 而非 401/403。""" + if not settings.PMS_AUTH_ENABLED: + return await call_next(request) + path = request.url.path + if authmod.is_public_path(path): + return await call_next(request) + if not settings.PMS_SESSION_SECRET: + # 开着登录却没配密钥: 失败即关门, 绝不用空密钥签/验票 + logger.error("PMS_AUTH_ENABLED 开着但 PMS_SESSION_SECRET 为空 —— 受保护请求一律拒绝") + return JSONResponse({"ok": False, "error": "服务未配置会话密钥 PMS_SESSION_SECRET"}, + status_code=503) + sess = authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE), + settings.PMS_SESSION_SECRET) + if not sess: + return JSONResponse({"ok": False, "error": "未登录或登录已过期", "auth": "login_required"}, + status_code=401) + if (authmod.authz_decision(request.method, path) == "admin" + and sess.get("role") != authmod.ROLE_ADMIN): + return JSONResponse({"ok": False, "error": "无权限: 该操作需要交易系统管理员", "auth": "forbidden"}, + status_code=403) + request.state.user = sess + return await call_next(request) + + def ok(fn, *args, **kw): """统一出参包装: 成功 {"ok":true, ...}; 失败 {"ok":false,"error":...} 且 HTTP 200。""" try: @@ -131,6 +160,59 @@ def index(): return JSONResponse({"hint": "页面文件缺失, 健康检查: /health"}) +# ================================================================ 登录与权限接口 +@app.post("/api/auth/login") +def api_auth_login(payload: dict = Body(default=None)): + """手机号+密码 -> 调 bshop 校验 -> 判角色 -> 签会话票写 cookie。前端发 JSON。""" + payload = payload or {} + phone = str(payload.get("phone") or "").strip() + password = str(payload.get("password") or "") + if not phone or not password: + return JSONResponse({"ok": False, "error": "手机号和密码都要填"}, status_code=400) + res = authmod.call_bshop_login(settings.PMS_BSHOP_LOGIN_URL, phone, password, + settings.PMS_BSHOP_TIMEOUT) + if not res.get("ok"): + return JSONResponse({"ok": False, "error": res.get("error") or "登录失败"}, status_code=401) + role = authmod.role_from_permissions(res["permissions"], settings.PMS_ADMIN_PERMS, + settings.PMS_TRADER_PERMS) + if role is None: + return JSONResponse({"ok": False, "error": "这个账号没有持仓管理系统的使用权限"}, status_code=403) + if not settings.PMS_SESSION_SECRET: + return JSONResponse({"ok": False, "error": "服务未配置会话密钥 PMS_SESSION_SECRET"}, status_code=503) + token = authmod.make_session(res["phone"], res["username"], role, + settings.PMS_SESSION_SECRET, settings.PMS_SESSION_TTL_HOURS) + resp = JSONResponse({"ok": True, "user": {"phone": res["phone"], "username": res["username"]}, + "role": role, "orgs": res.get("orgs") or []}) + resp.set_cookie(settings.PMS_SESSION_COOKIE, token, + max_age=settings.PMS_SESSION_TTL_HOURS * 3600, + httponly=True, samesite="lax", path="/") + return resp + + +@app.post("/api/auth/logout") +def api_auth_logout(): + """退出: 清掉会话 cookie。""" + resp = JSONResponse({"ok": True}) + resp.delete_cookie(settings.PMS_SESSION_COOKIE, path="/") + return resp + + +@app.get("/api/me") +def api_me(request: Request): + """当前登录人与角色。关掉登录时回一个合成的管理员, 前端就不弹登录框也不藏东西。""" + if not settings.PMS_AUTH_ENABLED: + return {"ok": True, "auth_enabled": False, + "user": {"phone": "-", "username": "本地(未开登录)"}, "role": authmod.ROLE_ADMIN} + secret = settings.PMS_SESSION_SECRET + sess = (authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE), secret) + if secret else None) + if not sess: + return JSONResponse({"ok": False, "auth": "login_required"}, status_code=401) + return {"ok": True, "auth_enabled": True, + "user": {"phone": sess.get("phone"), "username": sess.get("username")}, + "role": sess.get("role")} + + @app.get("/api/overview") def api_overview(): return ok(portfolio.overview) diff --git a/app/web/static/index.html b/app/web/static/index.html index c456d46..ed53949 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -344,11 +344,40 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} .oprow2 .d{font-size:11.5px;color:var(--ink-2);line-height:1.5;} .oprow2 .b{flex:none;width:92px;} .oprow2 .b .el-button{width:100%;} +/* 登录遮罩与登录人胶囊 (2026-08 加权限) */ +.login-mask{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center; + background:rgba(20,20,18,.55);backdrop-filter:blur(4px);} +.login-card{width:320px;background:var(--surface);border:1px solid var(--hair);border-radius:14px; + box-shadow:0 12px 40px rgba(0,0,0,.28);padding:26px 24px;} +.login-card h2{margin:0 0 4px;font-size:18px;} +.login-sub{color:var(--muted);font-size:13px;margin-bottom:16px;} +.login-in{width:100%;box-sizing:border-box;border:1px solid var(--hair);border-radius:9px; + padding:10px 12px;font-size:14px;margin-bottom:10px;background:var(--surface-2);color:var(--ink);} +.login-in:focus{outline:none;border-color:var(--accent);} +.login-err{color:var(--up);font-size:12.5px;margin:2px 0 10px;} +.login-btn{width:100%;border:0;border-radius:9px;background:var(--accent);color:#fff; + padding:11px;font-size:14px;font-weight:600;cursor:pointer;} +.login-btn:disabled{opacity:.6;cursor:default;} +.chip.hd-user{background:var(--accent-weak);color:var(--accent);} .chip.hd-user .dot{background:var(--accent);}
+ +
+ +
+

PMS 持仓管理v{{ health.version || '-' }} · {{ health.now || '' }}

{{ marketState }} @@ -376,7 +405,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
大盘偏热,系统已经停掉它自己发起的买入,以及策略里的买入动作。卖出不受影响,你自己下的命令也不受影响。
今日建议:{{ mzSig.advice.direction==='REDUCE' ? '降仓' : '升仓' }} {{ (mzSig.advice.pct*100).toFixed(1) }}% - +
{{ mzSig.advice.why }} · 当日有效,隔日作废
最近一条大盘冷热命令 {{ mzSig.last_command.command_id }} · {{ mzSig.last_command.cmd_type==='REDUCE_EXPOSURE' ? '降仓' : '升仓' }} · {{ mzSig.last_command.status }}
@@ -394,7 +423,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
- +
@@ -403,6 +432,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} {{ autoRefresh ? '自动' : '手动' }} · {{ lastRefresh }} + {{ me.username || me.phone }} · {{ me.role }} +