tradingSystem/app/web/auth.py

220 lines
9.0 KiB
Python
Raw Normal View History

2026-08-26 16:51:11 +08:00
# -*- 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