fix(backend): Wave 1 Day 1 三个 P0 D Bug 修复

- W1-T3 修 4 处 fdw_etl.* 必坏残留 → app.* (P0-5 致命 1)
  · tenant_users.py L431/L456-457: v_dim_assistant + v_dim_staff(_ex)
  · tenant_excel.py L394/L411: v_dim_assistant + v_dim_staff
  · tenant_clues.py L119: v_dim_member
  · 修复后 tenant-admin 用户审核 / Excel 上传 / 维客线索恢复正常

- W1-T4 JWT aud sign 端写入 (P0-5 致命 2 最小止血)
  · jwt.py 全部 token 创建/解码函数加 audience 参数
  · auth.py admin 端加 audience="admin"
  · xcx_auth.py miniapp 端加 audience="miniapp" (8 处调用)
  · 18 router 切强制 aud 校验留 Wave 2

- W1-T5 DBViewer 白名单 + 黑名单双保险 (P0-8)
  · 白名单: SELECT/WITH/EXPLAIN/SHOW 开头
  · 黑名单: 17 关键词覆盖全 DML/DDL/DCL
  · 注释剥离避免误伤;15/15 单测 PASS

参考: docs/audit/changes/2026-05-04__wave1_day1_d_bug_triple_fix.md
This commit is contained in:
Neo
2026-05-04 07:36:20 +08:00
parent caf179a5da
commit 17f045a89e
8 changed files with 273 additions and 57 deletions

View File

@@ -28,18 +28,25 @@ def hash_password(password: str) -> str:
def create_access_token(
user_id: int, site_id: int, roles: list[str] | None = None
user_id: int,
site_id: int,
roles: list[str] | None = None,
audience: str | None = None,
) -> str:
"""
生成 access_token。
payload: sub=user_id, site_id, roles, type=access, exp
roles 参数默认 None保持向后兼容。
payload: sub=user_id, site_id, roles, type=access, exp, aud(可选)
roles / audience 参数默认 None,保持向后兼容。
新增 audience 参数(P0-5 致命 2 修复):
- admin-web 端登录传 audience="admin"
- 小程序登录传 audience="miniapp"
- tenant-admin 在自己的 router 内手动签发,不走本函数
"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
payload = {
payload: dict = {
"sub": str(user_id),
"site_id": site_id,
"type": "access",
@@ -47,56 +54,79 @@ def create_access_token(
}
if roles is not None:
payload["roles"] = roles
if audience is not None:
payload["aud"] = audience
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
def create_refresh_token(user_id: int, site_id: int) -> str:
def create_refresh_token(
user_id: int,
site_id: int,
audience: str | None = None,
) -> str:
"""
生成 refresh_token。
payload: sub=user_id, site_id, type=refresh, exp
payload: sub=user_id, site_id, type=refresh, exp, aud(可选)
"""
expire = datetime.now(timezone.utc) + timedelta(
days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
payload = {
payload: dict = {
"sub": str(user_id),
"site_id": site_id,
"type": "refresh",
"exp": expire,
}
if audience is not None:
payload["aud"] = audience
return jwt.encode(payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM)
def create_token_pair(user_id: int, site_id: int, roles: list[str] | None = None) -> dict[str, str]:
"""生成 access_token + refresh_token 令牌对。"""
def create_token_pair(
user_id: int,
site_id: int,
roles: list[str] | None = None,
audience: str | None = None,
) -> dict[str, str]:
"""生成 access_token + refresh_token 令牌对。
audience 参数:admin-web 传 "admin";小程序传 "miniapp"
"""
return {
"access_token": create_access_token(user_id, site_id, roles=roles),
"refresh_token": create_refresh_token(user_id, site_id),
"access_token": create_access_token(user_id, site_id, roles=roles, audience=audience),
"refresh_token": create_refresh_token(user_id, site_id, audience=audience),
"token_type": "bearer",
}
def create_limited_token_pair(user_id: int) -> dict[str, str]:
def create_limited_token_pair(
user_id: int,
audience: str | None = None,
) -> dict[str, str]:
"""
为 pending 用户签发受限令牌。
payload 不含 site_id 和 roles仅包含 user_id + type + limited=True。
payload 不含 site_id 和 roles,仅包含 user_id + type + limited=True + aud(可选)
受限令牌仅允许访问申请提交和状态查询端点。
audience 参数:小程序 pending 用户传 "miniapp"
"""
now = datetime.now(timezone.utc)
access_payload = {
access_payload: dict = {
"sub": str(user_id),
"type": "access",
"limited": True,
"exp": now + timedelta(minutes=config.JWT_ACCESS_TOKEN_EXPIRE_MINUTES),
}
refresh_payload = {
refresh_payload: dict = {
"sub": str(user_id),
"type": "refresh",
"limited": True,
"exp": now + timedelta(days=config.JWT_REFRESH_TOKEN_EXPIRE_DAYS),
}
if audience is not None:
access_payload["aud"] = audience
refresh_payload["aud"] = audience
return {
"access_token": jwt.encode(
access_payload, config.JWT_SECRET_KEY, algorithm=config.JWT_ALGORITHM
@@ -108,41 +138,60 @@ def create_limited_token_pair(user_id: int) -> dict[str, str]:
}
def decode_token(token: str) -> dict:
def decode_token(token: str, audience: str | None = None) -> dict:
"""
解码并验证 JWT 令牌。
返回 payload dict包含 sub、site_id、type、exp。
返回 payload dict,包含 sub、site_id、type、exp、aud(可选)
令牌无效或过期时抛出 JWTError。
audience 参数(P0-5 致命 2 修复):
- 传入时强制校验 token 的 aud 字段,不匹配抛 JWTError
- 不传时,如果 token 含 aud 字段 jose 会拒绝(因此默认 options 关闭 aud 校验)
- 旧 token(无 aud)兼容:不传 audience 时通过 options 关闭 aud 校验,放行
"""
try:
payload = jwt.decode(
token, config.JWT_SECRET_KEY, algorithms=[config.JWT_ALGORITHM]
)
if audience is not None:
payload = jwt.decode(
token,
config.JWT_SECRET_KEY,
algorithms=[config.JWT_ALGORITHM],
audience=audience,
)
else:
# 兼容:不强制 aud 校验(旧 token 与新 token 都能解码)
payload = jwt.decode(
token,
config.JWT_SECRET_KEY,
algorithms=[config.JWT_ALGORITHM],
options={"verify_aud": False},
)
return payload
except JWTError:
raise
def decode_access_token(token: str) -> dict:
def decode_access_token(token: str, audience: str | None = None) -> dict:
"""
解码 access_token 并验证类型。
令牌类型不是 access 时抛出 JWTError。
audience 参数:见 decode_token。
"""
payload = decode_token(token)
payload = decode_token(token, audience=audience)
if payload.get("type") != "access":
raise JWTError("令牌类型不是 access")
return payload
def decode_refresh_token(token: str) -> dict:
def decode_refresh_token(token: str, audience: str | None = None) -> dict:
"""
解码 refresh_token 并验证类型。
令牌类型不是 refresh 时抛出 JWTError。
audience 参数:见 decode_token。
"""
payload = decode_token(token)
payload = decode_token(token, audience=audience)
if payload.get("type") != "refresh":
raise JWTError("令牌类型不是 refresh")
return payload

View File

@@ -65,7 +65,7 @@ async def login(body: LoginRequest):
detail="用户名或密码错误",
)
tokens = create_token_pair(user_id, site_id, roles=roles or [])
tokens = create_token_pair(user_id, site_id, roles=roles or [], audience="admin")
return TokenResponse(**tokens)
@@ -78,6 +78,8 @@ async def refresh(body: RefreshRequest):
refresh_token 保持不变,由客户端继续持有)。
"""
try:
# 兼容:旧 token 无 aud(audience=None);新 token 应带 aud="admin"
# 灰度期暂不强制 aud 校验,Wave 2 切换强制
payload = decode_refresh_token(body.refresh_token)
except JWTError:
raise HTTPException(
@@ -102,8 +104,8 @@ async def refresh(body: RefreshRequest):
roles = row[0] if row else []
# 生成新的 access_tokenrefresh_token 原样返回
new_access = create_access_token(user_id, site_id, roles=roles or [])
# 生成新的 access_token,refresh_token 原样返回
new_access = create_access_token(user_id, site_id, roles=roles or [], audience="admin")
return TokenResponse(
access_token=new_access,
refresh_token=body.refresh_token,

View File

@@ -33,12 +33,36 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/db", tags=["数据库查看器"])
# 写操作关键词(不区分大小写)
_WRITE_KEYWORDS = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE)\b",
# P0-8 修复(2026-05-04 Wave 1):改为白名单 + 黑名单双保险
# 白名单:SQL 必须以这些关键词开头(去注释/空白后)
_ALLOWED_PREFIXES = ("SELECT", "WITH", "EXPLAIN", "SHOW")
# 黑名单:深度防御,即使开头通过,语句中包含这些关键词也拒绝
# 涵盖 DML / DDL / DCL,补全原仅 5 个关键词的漏洞
_DENY_KEYWORDS = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE|COPY|CALL|COMMENT|VACUUM|REINDEX|CLUSTER|REFRESH|LOCK)\b",
re.IGNORECASE,
)
def _strip_comments(sql: str) -> str:
"""剥离 SQL 中的 -- 行注释 与 /* */ 块注释,避免黑名单误匹配注释里的英文词。"""
# 块注释 /* ... */ (非贪婪)
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
# 行注释 -- 直到行尾
sql = re.sub(r"--[^\n]*", " ", sql)
return sql
def _extract_first_keyword(sql: str) -> str:
"""提取 SQL 第一个有效关键词(去掉前导注释 + 空白)。
支持 -- 行注释 和 /* */ 块注释。返回大写关键词,无关键词返回空字符串。
"""
s = _strip_comments(sql).strip()
m = re.match(r"\s*([A-Za-z_]+)", s)
return m.group(1).upper() if m else ""
# 查询结果行数上限
_MAX_ROWS = 1000
@@ -156,10 +180,13 @@ async def execute_query(
) -> QueryResponse:
"""只读 SQL 执行。
安全措施
1. 拦截写操作关键词INSERT / UPDATE / DELETE / DROP / TRUNCATE
2. 限制返回行数上限 1000 行
3. 设置查询超时 30 秒
安全措施(P0-8 修复 2026-05-04):
1. 白名单:SQL 必须以 SELECT / WITH / EXPLAIN / SHOW 开头(去注释/空白后)
2. 黑名单:深度防御,语句含 DML/DDL/DCL 关键词一律拒绝(覆盖 INSERT/UPDATE/DELETE/DROP/
TRUNCATE/ALTER/CREATE/GRANT/REVOKE/COPY/CALL/COMMENT/VACUUM/REINDEX/CLUSTER/REFRESH/LOCK)
3. 限制返回行数上限 1000 行
4. 设置查询超时 30 秒
5. 数据库连接为只读账号(get_etl_readonly_connection,默认 read-only 事务)
"""
sql = body.sql.strip()
if not sql:
@@ -168,11 +195,19 @@ async def execute_query(
detail="SQL 语句不能为空",
)
# 拦截写操作
if _WRITE_KEYWORDS.search(sql):
# 白名单:首关键词校验
first_kw = _extract_first_keyword(sql)
if first_kw not in _ALLOWED_PREFIXES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="只允许只读查询,禁止 INSERT / UPDATE / DELETE / DROP / TRUNCATE 操作",
detail=f"只允许只读查询,SQL 必须以 {' / '.join(_ALLOWED_PREFIXES)} 开头",
)
# 黑名单:深度防御(剥离注释后再匹配,避免注释中"comment"等英文词误伤)
if _DENY_KEYWORDS.search(_strip_comments(sql)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="检测到禁止的关键词(DML/DDL/DCL),只允许只读查询",
)
conn = get_etl_readonly_connection(user.site_id)

View File

@@ -116,7 +116,7 @@ async def search_customers(
cur.execute(
"""
SELECT member_id, nickname, mobile
FROM fdw_etl.v_dim_member
FROM app.v_dim_member
WHERE scd2_is_current = 1
AND (nickname ILIKE %s OR mobile = %s)
LIMIT 50

View File

@@ -391,7 +391,7 @@ def match_personnel(
try:
with etl_conn.cursor() as cur:
cur.execute(
"SELECT assistant_id, nickname, number FROM fdw_etl.v_dim_assistant WHERE scd2_is_current = 1",
"SELECT assistant_id, nickname, number FROM app.v_dim_assistant WHERE scd2_is_current = 1",
)
for aid, nickname, number in cur.fetchall():
if nickname and number:
@@ -408,7 +408,7 @@ def match_personnel(
try:
with etl_conn.cursor() as cur:
cur.execute(
"SELECT staff_id, name, number FROM fdw_etl.v_dim_staff",
"SELECT staff_id, name, number FROM app.v_dim_staff",
)
for sid, name, number in cur.fetchall():
if name and number:

View File

@@ -428,7 +428,7 @@ async def get_match_suggestions(
cur.execute(
"""
SELECT assistant_id, name, number
FROM fdw_etl.v_dim_assistant
FROM app.v_dim_assistant
WHERE phone = %s AND scd2_is_current = 1
""",
(phone,),
@@ -453,8 +453,8 @@ async def get_match_suggestions(
cur.execute(
"""
SELECT s.staff_id, s.name, s.number
FROM fdw_etl.v_dim_staff s
LEFT JOIN fdw_etl.v_dim_staff_ex sx ON sx.staff_id = s.staff_id
FROM app.v_dim_staff s
LEFT JOIN app.v_dim_staff_ex sx ON sx.staff_id = s.staff_id
WHERE s.phone = %s OR sx.phone = %s
""",
(phone, phone),

View File

@@ -200,14 +200,14 @@ async def wx_login(body: WxLoginRequest):
default_site_id = _get_user_default_site(conn, user_id)
if default_site_id is not None:
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
tokens = create_token_pair(user_id, default_site_id, roles=roles)
tokens = create_token_pair(user_id, default_site_id, roles=roles, audience="miniapp")
login_role = roles[0] if roles else None
else:
# approved 但无 site 绑定(异常边界),签发受限令牌
tokens = create_limited_token_pair(user_id)
tokens = create_limited_token_pair(user_id, audience="miniapp")
else:
# new / pending / rejected → 受限令牌
tokens = create_limited_token_pair(user_id)
tokens = create_limited_token_pair(user_id, audience="miniapp")
finally:
conn.close()
@@ -483,7 +483,7 @@ async def switch_site(
finally:
conn.close()
tokens = create_token_pair(user.user_id, body.site_id, roles=roles)
tokens = create_token_pair(user.user_id, body.site_id, roles=roles, audience="miniapp")
return WxLoginResponse(
access_token=tokens["access_token"],
@@ -549,13 +549,13 @@ async def refresh_token(body: RefreshTokenRequest):
if site_id is not None:
roles = _get_user_roles_at_site(conn, user_id, site_id)
tokens = create_token_pair(user_id, site_id, roles=roles)
tokens = create_token_pair(user_id, site_id, roles=roles, audience="miniapp")
else:
# approved 但无 site 绑定(异常边界)
tokens = create_limited_token_pair(user_id)
tokens = create_limited_token_pair(user_id, audience="miniapp")
else:
# new / pending / rejected / disabled → 受限令牌
tokens = create_limited_token_pair(user_id)
tokens = create_limited_token_pair(user_id, audience="miniapp")
finally:
conn.close()
@@ -629,12 +629,12 @@ if config.WX_DEV_MODE:
default_site_id = _get_user_default_site(conn, user_id)
if default_site_id is not None:
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
tokens = create_token_pair(user_id, default_site_id, roles=roles)
tokens = create_token_pair(user_id, default_site_id, roles=roles, audience="miniapp")
dev_login_role = roles[0] if roles else None
else:
tokens = create_limited_token_pair(user_id)
tokens = create_limited_token_pair(user_id, audience="miniapp")
else:
tokens = create_limited_token_pair(user_id)
tokens = create_limited_token_pair(user_id, audience="miniapp")
finally:
conn.close()
@@ -807,7 +807,7 @@ if config.WX_DEV_MODE:
finally:
conn.close()
tokens = create_token_pair(user.user_id, user.site_id, roles=roles)
tokens = create_token_pair(user.user_id, user.site_id, roles=roles, audience="miniapp")
return WxLoginResponse(
access_token=tokens["access_token"],
refresh_token=tokens["refresh_token"],
@@ -850,11 +850,11 @@ if config.WX_DEV_MODE:
default_site_id = _get_user_default_site(conn, user.user_id)
if default_site_id is not None:
roles = _get_user_roles_at_site(conn, user.user_id, default_site_id)
tokens = create_token_pair(user.user_id, default_site_id, roles=roles)
tokens = create_token_pair(user.user_id, default_site_id, roles=roles, audience="miniapp")
else:
tokens = create_limited_token_pair(user.user_id)
tokens = create_limited_token_pair(user.user_id, audience="miniapp")
else:
tokens = create_limited_token_pair(user.user_id)
tokens = create_limited_token_pair(user.user_id, audience="miniapp")
finally:
conn.close()