Files
KCGL/inventory-backend/app/utils/decorators.py
yueli 0b75740ae0 feat: 权限系统重构 — 操作权限双向展开 + 启动时自动补全默认权限
## permission_service.py
- init_all_menus: menu_defs 新增 inbound_purchase(采购申请) 菜单项
- init_all_menus: 创建 inbound_purchase:operation 操作元素
- 新增 ensure_default_permissions(): 启动时从 sys_user 提取活跃角色,
  对空权限角色自动补全默认菜单(按角色类型分级)
- 自动迁移: 已有 inbound_buy 权限的角色 → 自动获得 inbound_purchase
- SUPERVISOR 启动时自动获得 inbound_purchase 菜单权限

## decorators.py
- _expand_operation_perms 改为双向桥接:
  正向(inbound_buy:operation←inbound_buy:write)→通过
  反向(inbound_buy←inbound_buy:operation)→通过(可编辑隐含可读)
- 新增详细诊断日志,记录展开路径和失败原因

## __init__.py
- 启动时调用 PermissionService.ensure_default_permissions()
2026-07-16 11:25:41 +08:00

291 lines
10 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
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__'
def audit_log(module: str = None, action: str = None, get_target_id_fn=None, get_target_name_fn=None, get_details_fn=None):
"""
已废弃!
由 SQLAlchemy 底层监听器app/core/audit_listener.py全面接管审计日志入库。
此装饰器保留空壳以防项目中其他文件 import 引用时报错。
"""
def wrapper(fn):
from functools import wraps
@wraps(fn)
def decorator(*inner_args, **inner_kwargs):
return fn(*inner_args, **inner_kwargs)
return decorator
return wrapper