Files
KCGL/inventory-backend/app/utils/decorators.py
yueli 4808a48594 refactor(audit): 审计架构清理——复活白名单监听器、停用噪声监听器、清除僵尸装饰器
一、统一为单一监听器实现
  原先两套 SQLAlchemy 事件监听器并存:
    · app/utils/audit_events.py   —— 全局监听 db.Model、无白名单、无请求上下文守卫(实际在跑)
    · app/core/audit_listener.py  —— 白名单制、有守卫、有模型级开关(从未生效)
  后者失效的根因:注册代码写在 extensions.py 的 init_extensions() 内,
  而该函数全仓库只有定义、没有任何调用(create_app 直接内联调用 db.init_app 等)。

  现统一由 app/core/audit_listener.py 承担,并在 create_app() 中显式注册。
  extensions.py 的死函数 init_extensions 整体删除,避免后人误以为它是有效入口。

二、修复监听器三处致命缺陷(此前注册了也写不进数据)
  1. 事件回调第二个参数是 Connection,原代码却调用 Connection.add()(不存在),
     每次写日志都抛 AttributeError 并被 except 吞掉 → 改为 connection.execute()
  2. register_audit_listeners 从 app.models 批量 import 多个未导出的模型,
     ImportError 被上层 try/except 吞掉 → 改为按表名从 db.metadata 取模型
  3. 本项目有 31 处函数体内延迟导入模型(如 scrap.py 内部才 import ScrapApproval),
     一次性注册会静默漏表 → 增加 ensure_audit_listeners() 惰性补绑,
     并在模型预加载段补全审批单/BOM/采购等模型

三、强约束
  · WHITELIST_TABLES:仅 18 张核心业务表,系统表/草稿表/向量表不再自审
  · has_request_context() 守卫:系统初始化与后台定时任务不再产生 username=system 噪声
  · IGNORE_FIELDS 增加 password/password_hash/salt/token/secret/api_key(安全红线)
  · created_at 显式写 beijing_time(),与全系统时间口径一致

四、清除僵尸装饰器
  @audit_log 早已退化为直接透传的空壳(module/action 参数全被忽略,
  数据库中零星的中文 action 即其历史遗留产物),却仍挂在 38 处路由上。
  连同 13 个文件的 import 一并移除;audit_events.register_audit_events 改为空操作。

验证:应用上下文中的写操作不产生日志;HTTP 请求产生 5 条日志,
对象为业务单号(APR-SCRAP-... / SKU),模块中文,操作人真实,时间为北京时间。
2026-09-10 14:16:27 +08:00

368 lines
13 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.

# app/utils/decorators.py
from functools import wraps
from flask_jwt_extended import get_jwt, verify_jwt_in_request, get_jwt_identity
from flask import jsonify, g, request, current_app, has_request_context
import logging
import json
def _verify_user_active():
"""
JWT「幽灵令牌」安全漏洞修复:
在 Token 签名验证通过之后,进一步检查用户在数据库中是否仍然存在且未被禁用。
调用时机:login_required / permission_required 装饰器中,
在 verify_jwt_in_request() 成功之后立即调用。
返回 True → 用户正常,放行
返回 False → 用户已从数据库删除或被禁用,阻断请求
"""
try:
claims = get_jwt()
user_id = claims.get('sub')
if user_id is None:
return True
from app.models.system import SysUser
user = SysUser.query.get(user_id)
if user is None:
current_app.logger.warning(
f"🚫 [Ghost Token Blocked] user_id={user_id} not found in database (deleted account)"
)
return False
if user.status != 'active':
current_app.logger.warning(
f"🚫 [Token Blocked] user_id={user_id} status={user.status} (disabled account)"
)
return False
return True
except Exception as e:
current_app.logger.error(f"User active check error: {e}")
return True # 出错时 fail-open,避免数据库故障导致全站不可用
def _verify_token_in_redis():
"""
验证当前 Token 是否与 Redis 中存储的 Token 一致(单设备登录互踢)
"""
from app.extensions import redis_client
from flask import current_app
if redis_client is None:
return True
try:
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return True
request_token = auth_header[7:]
claims = get_jwt()
user_id = claims.get('sub')
if user_id is None:
return True
stored_token = redis_client.get(f"user_token_{user_id}")
if stored_token is None:
return True
if request_token != stored_token:
current_app.logger.warning(f"Token mismatch for user {user_id}: request token != stored token")
return False
return True
except Exception as e:
current_app.logger.error(f"Redis token verification error: {e}")
return True
def _raise_token_mismatch_error():
"""抛出 Token 不一致的错误"""
return jsonify({
'msg': '您的账号已在其他设备登录,请重新登录',
'code': 401,
'reason': 'token_mismatch'
}), 401
def role_required(*roles):
"""自定义装饰器:检查用户角色"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
claims = get_jwt()
user_role = claims.get('role')
user_role_upper = user_role.upper() if user_role else None
if user_role_upper == 'SUPER_ADMIN':
return fn(*args, **kwargs)
if user_role_upper not in [r.upper() for r in roles]:
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
def login_required(fn):
"""
验证 JWT 令牌是否存在且有效,并检查用户是否仍在数据库中且未被禁用。
双重防护:1) Token 签名验证 2) 数据库用户存在性 3) Redis 单设备互踢
"""
@wraps(fn)
def decorator(*args, **kwargs):
try:
verify_jwt_in_request()
except Exception as e:
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
if not _verify_user_active():
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
return fn(*args, **kwargs)
return decorator
def _expand_operation_perms(permission_code, all_perms):
"""
操作权限自动展开映射器(双向粒度桥接)。
"""
if permission_code in all_perms:
logging.info(f"[权限展开] 精确匹配: {permission_code}")
return True
KNOWN_OPERATION_SUFFIXES = (
':operation', ':add', ':edit', ':delete', ':submit',
':approve', ':dispatch', ':write'
)
if ':' in permission_code:
prefix = permission_code.rsplit(':', 1)[0]
else:
prefix = permission_code
# 收集该模块下的所有用户权限,用于诊断日志
module_perms = [p for p in all_perms if p.startswith(prefix)]
for perm in all_perms:
if perm.startswith(prefix + ':') or perm == prefix:
for suffix in KNOWN_OPERATION_SUFFIXES:
if perm.endswith(suffix):
logging.info(f"[权限展开] {permission_code} ← 用户有 {perm} → 通过")
return True
if ':' not in permission_code and ':' in perm:
logging.info(f"[权限展开] {permission_code} ← 用户有 {perm}(下级权限)→ 通过")
return True
logging.warning(
f"[权限展开] 失败: 要求={permission_code}, "
f"该模块用户权限={module_perms}, 全部权限数={len(all_perms)}"
)
return False
def permission_required(permission_code):
"""检查当前用户是否拥有指定权限码,同时检查用户是否仍然有效"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
try:
verify_jwt_in_request()
except Exception as e:
logging.warning(f"JWT verification failed: {e}")
return jsonify(msg='登录已过期,请重新登录'), 401
# ★ 幽灵令牌漏洞修复:检查用户是否已从数据库删除或被禁用
if not _verify_user_active():
return jsonify(msg='账号已失效(已删除或已禁用),请重新登录'), 401
if not _verify_token_in_redis():
return _raise_token_mismatch_error()
claims = get_jwt()
user_role = claims.get('role')
if user_role and user_role.upper() == 'SUPER_ADMIN':
return fn(*args, **kwargs)
# 获取当前用户公司,用于权限隔离
user_company = claims.get('company_name', '')
try:
from app.services.auth_service import AuthService
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
except Exception as e:
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
return jsonify(msg='权限查询失败'), 403
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
# ★ 操作权限展开检查(粒度桥接)
if not _expand_operation_perms(permission_code, all_perms):
logging.warning(
f"权限检查失败: 角色={user_role}, 所需={permission_code}, "
f"拥有={[p for p in all_perms if permission_code.split(':')[0] in p]}"
)
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper
def _has_cross_domain_permission(user_role, user_company=''):
"""
检查指定角色是否拥有「全局跨域访问」权限。
复用 AuthService.get_user_permissions 统一查询。
"""
if not user_role:
return False
try:
from app.services.auth_service import AuthService
perm_dict = AuthService.get_user_permissions(user_role, company_name=user_company)
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
return 'crossDomain' in all_perms
except Exception:
return False
# 能查看“他人借还/出库记录”的管理者角色(仓库管理员/主管/超管)。普通申请人(含入库员/出库员等)只能看自己的记录。
# 注意:入库员(INBOUND)、出库员(OUTBOUND)不是管理者视角,不应放此名单;如需让他们看全部请确认为库管角色后再加入。
PRIVILEGED_VIEWER_ROLES = ('SUPER_ADMIN', 'SUPERVISOR', 'WAREHOUSE_MGR')
def is_privileged_viewer():
"""
判断当前登录用户是否属于“管理者视角”(可查看他人借还/出库记录)。
规则:角色为 SUPER_ADMIN/SUPERVISOR/WAREHOUSE_MGR,或拥有 crossDomain 权限。
普通申请人(其余角色)只能看自己的记录(由各列表 handler 强制 applicant_id=当前用户)。
"""
from flask_jwt_extended import get_jwt
claims = get_jwt()
role = (claims.get('role') or '').upper()
if role in PRIVILEGED_VIEWER_ROLES:
return True
user_company = claims.get('company_name', '')
if _has_cross_domain_permission(role, user_company):
return True
return False
def get_current_company_filter():
"""
多租户数据权限隔离工具函数。
权限逻辑(基于权限码 crossDomain,支持动态分配):
- SUPER_ADMIN → None(全量跨域)
- 拥有 crossDomain 权限 → None(全量跨域)
- 普通用户 → 强制隔离到 JWT 中记录的公司
返回值:
None → 不限制公司
str → 仅查询该公司数据
使用示例:
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.filter(MaterialBase.company_name == company_limit)
"""
from flask import request
claims = get_jwt()
user_role = claims.get('role', '')
# 规范化角色为大写
if user_role:
user_role = user_role.upper()
# 从请求参数获取前端指定的公司(兼容 company_name 和 company 两个参数名)
req_company = request.args.get('company_name', '') or request.args.get('company', '')
# 超级管理员 → 全局跨域
if user_role == 'SUPER_ADMIN':
if req_company and req_company.strip().upper() != 'ALL':
return req_company.strip()
return None
# 动态权限码检查:拥有 crossDomain 权限 → 全局跨域
user_company = claims.get('company_name', '')
if user_role and _has_cross_domain_permission(user_role, user_company):
if req_company and req_company.strip().upper() != 'ALL':
return req_company.strip()
return None
# 普通用户 → 强制隔离到本公司
# 如果 JWT 中没有 company_name,返回哨兵值确保不会匹配任何数据
if user_company:
return user_company
return '__NO_COMPANY__'
# [已移除] audit_log 装饰器
#
# 该装饰器已废弃多年:它只是一个直接透传的空壳,其 module / action 参数
# 全部被忽略(数据库中零星的中文 action 即其历史遗留产物)。审计日志现由
# app/core/audit_listener.py 的 SQLAlchemy 事件监听器统一接管。
#
# 为避免继续造成"以为它在工作"的误解,已连同 38 处路由引用一并删除。
def prevent_double_submit(lock_timeout=5):
"""
Redis 幂等锁装饰器 — 防止重复提交。
原理:
- 基于 (user_id, request.path, hash(body)) 生成唯一 Redis key
- 若 key 存在 → 返回 409 Conflict
- 若不存在 → setex(key, timeout, "1") → 执行业务 → delete(key)
用法:
@app.route('/submit', methods=['POST'])
@prevent_double_submit(lock_timeout=10)
def submit(): ...
"""
import hashlib
from app.extensions import redis_client
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
if redis_client is None:
return fn(*args, **kwargs) # Redis 不可用时 fail-open
try:
from flask_jwt_extended import get_jwt_identity
user_id = str(get_jwt_identity() or 'anonymous')
path = request.path
# 对请求体做确定性哈希(空 body = 空串 hash)
body_str = ''
if request.is_json:
body_str = json.dumps(request.get_json() or {}, sort_keys=True)
body_hash = hashlib.md5(body_str.encode()).hexdigest()[:12]
lock_key = f"idem:{user_id}:{path}:{body_hash}"
if redis_client.exists(lock_key):
logging.warning(f"[幂等锁] 重复请求被拦截: {lock_key}")
return jsonify({
'code': 409,
'msg': '请求正在处理中,请勿重复提交'
}), 409
redis_client.setex(lock_key, lock_timeout, "1")
try:
return fn(*args, **kwargs)
finally:
try:
redis_client.delete(lock_key)
except Exception:
pass # 清理失败不影响业务
except Exception as e:
logging.warning(f"[幂等锁] Redis 异常,降级放行: {e}")
return fn(*args, **kwargs) # fail-open
return decorator
return wrapper