tradingSystem/app/web/auth.py

220 lines
9.0 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 调用隔离出来,其余全部可离线测。
三块职责:
一、认证:解析第三方登录系统 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