tradingSystem/app/web/auth.py

221 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
PMS 登录与权限 —— 认证解析 / 角色判定 / 会话票签验 / 接口鉴权判定(纯函数)
=======================================================================
方案见「PMS 加登录与两个角色(系统管理员 / 交易员)」。本模块刻意只放纯函数:
不连库、几乎不联网,喂进去字典或字符串就能算,便于单测
scripts/test_batch18_units.py。唯一碰网络的是 call_bshop_login 一个函数,
它把真正的 HTTP 调用隔离出来,其余全部可离线测。
一个人可以同时持有多个角色(既是系统管理员又是交易员),所以角色一律按「集合」处理,
不折叠成单一角色。
会话票格式base64url( json({phone, username, roles, exp}) ) + "." + base64url( hmac_sha256 )
—— 只用标准库 hmac / hashlib不引新依赖。exp 是 Unix 秒。roles 是角色字符串列表。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import List, Optional
# 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。
ROLE_ADMIN = "系统管理员"
ROLE_TRADER = "交易员"
# ============================================================ 一、认证:解析 bshop 返回
def parse_bshop_login(resp_json: dict) -> dict:
"""把 bshop 登录返回解析成统一结构(不判角色,角色交给 roles_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 roles_from_permissions(permissions, admin_perms_csv: str,
trader_perms_csv: str = "") -> List[str]:
"""返回这个人持有的**全部**角色(一个人可以同时是系统管理员和交易员)。
- 系统管理员:权限里含 PMS_ADMIN_PERMS 任一条。
- 交易员:未配 PMS_TRADER_PERMS留空=人人都是交易员),或权限里含其任一条。
两个角色都不占 -> 空列表,代表无权进入 PMS。
permissions 是 "system_key:menu_key" 字符串列表parse_bshop_login 已归一)。
返回顺序固定为 [系统管理员, 交易员] 中此人占到的那些。
"""
have = set(permissions or [])
admin = _markers(admin_perms_csv)
trader = _markers(trader_perms_csv)
roles: List[str] = []
if admin & have:
roles.append(ROLE_ADMIN)
if (not trader) or (trader & have):
roles.append(ROLE_TRADER)
return roles
def is_admin(roles) -> bool:
"""会话/前端统一用这个判管理员,避免各处各写一遍。"""
return ROLE_ADMIN in (roles or [])
# ============================================================ 三、会话票签验
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, roles, 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, roles, secret: str,
ttl_hours: int, now: Optional[float] = None) -> str:
"""签发一张带过期时间的会话票。roles 是角色列表。now 可注入,便于单测。"""
base = time.time() if now is None else now
exp = int(base + int(ttl_hours) * 3600)
return sign_session({"phone": phone, "username": username,
"roles": list(roles), "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
# ============================================================ 四、接口鉴权判定
# 分工按前端两块视图划分(用户 2026-08 拍板):交易员工作台(前端 mode=trader上能点到
# 的都归交易员只有运维视图mode=ops里的系统维护操作归系统管理员。所以这里反过来只
# 登记「只给管理员」的那一小撮运维接口,其余已登录的人(交易员)默认放行 —— 交易员的能力
# 跟着他那一页走:日常下单、撤单、拍板、策略、参数微调、大盘冷热采纳,都算交易员的活。
# 只给管理员的接口族(前缀匹配,任何方法都算):
_ADMIN_PREFIX = [
("api", "ops"), # 回放 / 对账 / 日终 / 日报 / 择时扫描 / plan-refresh 等全部运维操作
("api", "ws-channel"), # ws 直连通道管理
("api", "industry"), # 行业划分数据导入
]
# 只给管理员的单个接口(精确匹配方法与整条路径):
_ADMIN_EXACT = [
("POST", ("api", "proposals")), # 人工补录提议:影子期造数调试口,没有交易员入口
]
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"(任何登录人可)。
规则:命中「只给管理员」的运维接口族,或那条人工补录提议的调试口,就要管理员;其余
已登录的人都放行。只对「已登录、且不在公开白名单」的请求调用(白名单在 is_public_path
"""
method = (method or "").upper()
segs = _seg(path)
for m, pat in _ADMIN_EXACT:
if m == method and _match(pat, segs):
return "admin"
for pref in _ADMIN_PREFIX:
if len(segs) >= len(pref) and tuple(segs[:len(pref)]) == pref:
return "admin"
return "any"
# 公开路径:不需要登录也放行。首页 / 必须放行,否则登录框自己都加载不出来。
_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