添加权限

This commit is contained in:
zlt 2026-08-27 10:16:45 +08:00
parent fcc39984a9
commit 5bb08fe5b6
6 changed files with 92 additions and 83 deletions

View File

@ -7,14 +7,11 @@ 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
会话票格式base64url( json({phone, username, roles, exp}) ) + "." + base64url( hmac_sha256 )
只用标准库 hmac / hashlib不引新依赖exp Unix roles 是角色字符串列表
"""
from __future__ import annotations
@ -23,7 +20,7 @@ import hashlib
import hmac
import json
import time
from typing import Optional
from typing import List, Optional
# 角色常量:既是写进会话票的字面值,也是回给前端、供前端判断的字面值。
ROLE_ADMIN = "系统管理员"
@ -32,7 +29,7 @@ ROLE_TRADER = "交易员"
# ============================================================ 一、认证:解析 bshop 返回
def parse_bshop_login(resp_json: dict) -> dict:
"""把 bshop 登录返回解析成统一结构(不判角色,角色交给 role_from_permissions
"""把 bshop 登录返回解析成统一结构(不判角色,角色交给 roles_from_permissions
入参是已经 json 解析好的字典真正发 HTTP 的是 call_bshop_login
成功: {"ok": True, "phone", "username", "permissions": [...], "orgs": [...]}
@ -83,28 +80,35 @@ def call_bshop_login(login_url: str, phone: str, password: str, timeout: int = 1
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无权进入
- 否则 -> 交易员
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:
return ROLE_ADMIN
if trader and not (trader & have):
return None
return ROLE_TRADER
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 [])
# ============================================================ 三、会话票签验
@ -117,7 +121,7 @@ def _b64d(s: str) -> bytes:
def sign_session(payload: dict, secret: str) -> str:
"""{phone, username, role, exp} 签成会话票字符串。"""
"""{phone, username, roles, exp} 签成会话票字符串。"""
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"),
sort_keys=True).encode("utf-8")
b = _b64e(body)
@ -125,12 +129,13 @@ def sign_session(payload: dict, secret: str) -> str:
return b + "." + _b64e(sig)
def make_session(phone: str, username: str, role: str, secret: str,
def make_session(phone: str, username: str, roles, secret: str,
ttl_hours: int, now: Optional[float] = None) -> str:
"""签发一张带过期时间的会话票。now 可注入,便于单测。"""
"""签发一张带过期时间的会话票。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, "role": role, "exp": exp}, secret)
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]:

View File

@ -71,8 +71,8 @@ async def _auth_guard(request: Request, call_next):
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"},
and not authmod.is_admin(sess.get("roles"))):
return JSONResponse({"ok": False, "error": "无权限: 该操作需要系统管理员", "auth": "forbidden"},
status_code=403)
request.state.user = sess
return await call_next(request)
@ -173,16 +173,16 @@ def api_auth_login(payload: dict = Body(default=None)):
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:
roles = authmod.roles_from_permissions(res["permissions"], settings.PMS_ADMIN_PERMS,
settings.PMS_TRADER_PERMS)
if not roles:
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,
token = authmod.make_session(res["phone"], res["username"], roles,
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 []})
"roles": roles, "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="/")
@ -202,7 +202,7 @@ def api_me(request: Request):
"""当前登录人与角色。关掉登录时回一个合成的管理员, 前端就不弹登录框也不藏东西。"""
if not settings.PMS_AUTH_ENABLED:
return {"ok": True, "auth_enabled": False,
"user": {"phone": "-", "username": "本地(未开登录)"}, "role": authmod.ROLE_ADMIN}
"user": {"phone": "-", "username": "本地(未开登录)"}, "roles": [authmod.ROLE_ADMIN, authmod.ROLE_TRADER]}
secret = settings.PMS_SESSION_SECRET
sess = (authmod.verify_session(request.cookies.get(settings.PMS_SESSION_COOKIE), secret)
if secret else None)
@ -210,7 +210,7 @@ def api_me(request: Request):
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")}
"roles": sess.get("roles") or []}
@app.get("/api/overview")

View File

@ -432,7 +432,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="muted hd-refresh-at" style="font-size:11.5px;cursor:pointer" @click="autoRefresh=!autoRefresh"
:title="autoRefresh?'自动刷新开,点此关闭':'自动刷新关,点此开启'">{{ autoRefresh ? '自动' : '手动' }}<span v-if="lastRefresh"> · {{ lastRefresh }}</span></span>
<button class="tbtn pri" :disabled="loading" @click="loadAll">{{ loading ? '刷新中…' : '刷新' }}</button>
<span class="chip hd-user" v-if="authed" :title="me.phone"><span class="dot"></span>{{ me.username || me.phone }} · {{ me.role }}</span>
<span class="chip hd-user" v-if="authed" :title="me.phone"><span class="dot"></span>{{ me.username || me.phone }} · {{ rolesText }}</span>
<button class="tbtn sm" v-if="authed" @click="doLogout">退出</button>
</div>
@ -1965,8 +1965,9 @@ createApp({
const mode = ref('trader');
// ---- 登录与角色 (2026-08 加权限)
const authed = ref(false), authReady = ref(false);
const me = ref({ phone: '', username: '', role: '' });
const isAdmin = computed(() => me.value.role === '交易系统管理员');
const me = ref({ phone: '', username: '', roles: [] });
const isAdmin = computed(() => (me.value.roles || []).includes('系统管理员'));
const rolesText = computed(() => (me.value.roles || []).join(' · '));
const loginForm = reactive({ phone: '', password: '' });
const loginBusy = ref(false), loginErr = ref('');
_onUnauth = () => { authed.value = false; };
@ -2404,7 +2405,7 @@ createApp({
const st = e && e.response && e.response.status;
if (st === 401) { authed.value = false; return { ok: false, error: '未登录' }; }
if (st === 403) {
const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要交易系统管理员';
const m = (e.response && e.response.data && e.response.data.error) || '无权限: 需要系统管理员';
ElementPlus.ElMessage.error(m); return { ok: false, error: m };
}
err.value = url + ' → ' + e.message; return { ok: false, error: e.message };
@ -2416,7 +2417,7 @@ createApp({
try {
const d = (await api.get('/api/me')).data || {};
if (d.ok) {
me.value = { phone: d.user.phone, username: d.user.username, role: d.role };
me.value = { phone: d.user.phone, username: d.user.username, roles: d.roles || [] };
authed.value = true;
if (!isAdmin.value && mode.value === 'ops') mode.value = 'trader';
} else { authed.value = false; }
@ -2440,7 +2441,7 @@ createApp({
async function doLogout() {
try { await api.post('/api/auth/logout', {}); } catch (e) {}
authed.value = false;
me.value = { phone: '', username: '', role: '' };
me.value = { phone: '', username: '', roles: [] };
}
async function loadParams() {
@ -3190,7 +3191,7 @@ createApp({
msgLatest, msgLevelClass, setMsgKind, msgGo,
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
lFold, rFold, railOpen, foldRail, closeRail, openRailSide,
authed, authReady, me, isAdmin, loginForm, loginBusy, loginErr, doLogin, doLogout,
authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout,
sectClosed, toggleSect };
}
}).use(ElementPlus).mount('#app');

View File

@ -50,7 +50,7 @@ class Settings(BaseSettings):
PMS_WEB_PORT: int = 38100
# =========================================================
# 一之二、登录与权限 (PMS 自带登录 + 两个角色: 交易系统管理员 / 交易员)
# 一之二、登录与权限 (PMS 自带登录 + 两个角色: 系统管理员 / 交易员)
# =========================================================
# 认证接第三方登录系统 bshop: 手机号+密码, 登录成功的返回里直接带这个人的权限清单。
# 角色从返回的 permissions 里认「管理员标记」权限得出; 会话用 PMS 自签票 (HMAC) 维持,
@ -62,7 +62,7 @@ class Settings(BaseSettings):
PMS_SESSION_COOKIE: str = "pms_session" # 会话 cookie 名
PMS_BSHOP_LOGIN_URL: str = "https://bshop.bmbs.tech/authProxy/api/auth/login" # 第三方登录接口
PMS_BSHOP_TIMEOUT: int = 10 # 调 bshop 登录的超时 (秒)
PMS_ADMIN_PERMS: str = "pms:admin" # 「交易系统管理员」标记权限, "system_key:menu_key", 逗号分隔可多条
PMS_ADMIN_PERMS: str = "pms:admin" # 「系统管理员」标记权限, "system_key:menu_key", 逗号分隔可多条
PMS_TRADER_PERMS: str = "" # 进入 PMS 至少要占的权限; 留空=任何能登录的人都算交易员
# =========================================================

View File

@ -33,7 +33,7 @@
名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/
判分脚本聚合与对照分组 (41 )
test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/
接口鉴权(写操作默认拒绝) (14 )
接口鉴权(维护类归管理员) (16 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
560
任一子集失败即整体失败 (退出码 1)

View File

@ -1,15 +1,12 @@
# -*- coding: utf-8 -*-
"""
第十八批: 登录与权限 (系统管理员 / 交易员) 的纯逻辑 不联网不连库
第十八批: 登录与权限 (系统管理员 / 交易员, 可同时持有) 的纯逻辑 不联网不连库
=====================================================================
本批只测 app/web/auth.py 里的纯函数, 不导入 app.web.main (那会拉起 fastapi 与一堆
基础设施依赖)覆盖四件事:
1. 角色判定 role_from_permissions: 管理员标记命中/未中交易员准入闸放行与拦截;
2. 会话票 make_session / verify_session: 签发-校验往返, 篡改/过期/换密钥/空密钥一律拒;
3. bshop 返回解析 parse_bshop_login: 喂真实样例抽出手机号/用户名/权限/组织, code!=200 判失败;
4. 接口鉴权 authz_decision + is_public_path: 分工按前端两块视图划分 交易员工作台上能点到
的都归交易员 (下单/撤单/拍板/策略/参数微调/大盘冷热采纳), 只有运维视图里的系统维护
操作 (ops/* ws-channelindustry 导入人工补录提议) 归管理员; 公开白名单放行首页与登录接口
只测 app/web/auth.py 里的纯函数, 不导入 app.web.main覆盖四件事:
1. 角色判定 roles_from_permissions: 返回**角色集合**, 一个人可同时是管理员和交易员;
2. 会话票 make_session / verify_session: roles 列表原样签发-校验, 篡改/过期/换密钥/空票一律拒;
3. bshop 返回解析 parse_bshop_login: 喂真实样例抽字段, code!=200 判失败;
4. 接口鉴权 authz_decision + is_public_path: 只有运维维护类接口归管理员, 其余登录人都放行
运行: python scripts/test_batch18_units.py
"""
import os
@ -30,7 +27,6 @@ def case(name):
return deco
# 与用户给的 bshop 登录成功返回同构的样例 (裁剪了权限条数, 字段与层级一致)
SAMPLE = {
"code": 200, "msg": "登录成功",
"data": {
@ -49,49 +45,56 @@ SAMPLE = {
}
# ---------------------------------------------------------------- 一、角色判定
@case("角色: 命中管理员标记 => 管理员")
def _role_admin():
r = A.role_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
assert r == A.ROLE_ADMIN, r
# ---------------------------------------------------------------- 一、角色判定(可多角色)
@case("角色: 管理员标记 + 交易员开放 => 同时是管理员和交易")
def _roles_both_open():
r = A.roles_from_permissions(["band:bkld", "pms:admin"], "pms:admin", "")
assert r == [A.ROLE_ADMIN, A.ROLE_TRADER], r
@case("角色: 只有普通权限 => 交易员")
def _role_trader():
r = A.role_from_permissions(["band:bkld", "band:yhyqx"], "pms:admin", "")
assert r == A.ROLE_TRADER, r
@case("角色: 只有普通权限 (交易员开放) => 交易员")
def _roles_trader_only():
r = A.roles_from_permissions(["band:bkld"], "pms:admin", "")
assert r == [A.ROLE_TRADER], r
@case("角色: 配了交易员准入且占其一 => 交易")
def _role_trader_gate_pass():
r = A.role_from_permissions(["band:ccgl"], "pms:admin", "band:ccgl,pms:-")
assert r == A.ROLE_TRADER, r
@case("角色: 配了交易员准入, 有管理员无交易员权限 => 只管理")
def _roles_admin_only():
r = A.roles_from_permissions(["pms:admin"], "pms:admin", "band:ccgl")
assert r == [A.ROLE_ADMIN], r
@case("角色: 配了交易员准入却一条不占 => None (无权进入)")
def _role_denied():
r = A.role_from_permissions(["band:bkld"], "pms:admin", "band:ccgl,pms:-")
assert r is None, r
@case("角色: 配了交易员准入, 两者都占 => 同时两个角色 (用户场景)")
def _roles_both_gated():
r = A.roles_from_permissions(["pms:admin", "band:ccgl"], "pms:admin", "band:ccgl")
assert r == [A.ROLE_ADMIN, A.ROLE_TRADER], r
@case("角色: 管理员标记可多条, 命中任一即算")
def _role_admin_multi():
r = A.role_from_permissions(["band:yhyqx"], "pms:admin, band:yhyqx", "")
assert r == A.ROLE_ADMIN, r
@case("角色: 配了交易员准入却一条不占 => 空 (无权进入)")
def _roles_denied():
r = A.roles_from_permissions(["band:x"], "pms:admin", "band:ccgl")
assert r == [], r
@case("角色: is_admin 辅助判定")
def _is_admin_helper():
assert A.is_admin([A.ROLE_ADMIN, A.ROLE_TRADER]) is True
assert A.is_admin([A.ROLE_TRADER]) is False
assert A.is_admin([]) is False
# ---------------------------------------------------------------- 二、会话票
@case("会话: 签发-校验往返, payload 字段齐全")
@case("会话: roles 列表签发-校验往返, 字段齐全")
def _sess_roundtrip():
tok = A.make_session("18795373439", "老王", A.ROLE_ADMIN, "s3cr3t", 12, now=1000.0)
tok = A.make_session("18795373439", "老王", [A.ROLE_ADMIN, A.ROLE_TRADER], "s3cr3t", 12, now=1000.0)
p = A.verify_session(tok, "s3cr3t", now=1001.0)
assert p and p["phone"] == "18795373439" and p["username"] == "老王", p
assert p["role"] == A.ROLE_ADMIN and p["exp"] == 1000 + 12 * 3600, p
assert p["roles"] == [A.ROLE_ADMIN, A.ROLE_TRADER] and p["exp"] == 1000 + 12 * 3600, p
@case("会话: 篡改正文或签名 => None")
def _sess_tampered():
tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 12, now=1000.0)
tok = A.make_session("186", "u", [A.ROLE_TRADER], "s3cr3t", 12, now=1000.0)
b, sig = tok.split(".", 1)
assert A.verify_session(b + "x." + sig, "s3cr3t", now=1001.0) is None
assert A.verify_session(b + "." + sig + "x", "s3cr3t", now=1001.0) is None
@ -99,7 +102,7 @@ def _sess_tampered():
@case("会话: 过期 / 换密钥 / 空密钥 / 空票 一律拒")
def _sess_reject():
tok = A.make_session("186", "u", A.ROLE_TRADER, "s3cr3t", 1, now=1000.0)
tok = A.make_session("186", "u", [A.ROLE_TRADER], "s3cr3t", 1, now=1000.0)
assert A.verify_session(tok, "s3cr3t", now=1000.0 + 3600 + 1) is None, "过期没拒"
assert A.verify_session(tok, "other", now=1001.0) is None, "换密钥没拒"
assert A.verify_session(tok, "", now=1001.0) is None, "空密钥没拒"
@ -117,11 +120,11 @@ def _parse_ok():
assert len(r["orgs"]) == 2 and r["orgs"][0]["org_name"] == "高姐", r["orgs"]
@case("解析: 样例接角色 —— 默认标记判交易员, 复用 band:yhyqx 判管理")
def _parse_then_role():
@case("解析: 样例接角色 —— 默认标记只交易员, band:yhyqx 标记则管理员+交易")
def _parse_then_roles():
r = A.parse_bshop_login(SAMPLE)
assert A.role_from_permissions(r["permissions"], "pms:admin", "") == A.ROLE_TRADER
assert A.role_from_permissions(r["permissions"], "band:yhyqx", "") == A.ROLE_ADMIN
assert A.roles_from_permissions(r["permissions"], "pms:admin", "") == [A.ROLE_TRADER]
assert A.roles_from_permissions(r["permissions"], "band:yhyqx", "") == [A.ROLE_ADMIN, A.ROLE_TRADER]
@case("解析: code!=200 / 缺手机号 / 非字典 => 失败")