bootstrap 菜单和角色权限定义中遗漏了 ai:parse-order, 导致 salesman 角色调用 /api/ai/parse-order 时权限校验失败返回 403。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
385 lines
15 KiB
Python
385 lines
15 KiB
Python
"""认证授权服务层。
|
||
|
||
负责用户登录、登出、Token 管理、权限菜单加载等认证授权业务逻辑。
|
||
依赖 SystemRepository 进行用户和角色数据查询,
|
||
依赖 security 模块进行密码验证和 JWT Token 生成/解析。
|
||
支持将已吊销的 Token 持久化到本地文件,实现跨进程的登出生效。
|
||
"""
|
||
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
from sqlalchemy.orm import Session
|
||
|
||
from backend.app.core.config import get_settings
|
||
from backend.app.core.error_codes import ErrorCode
|
||
from backend.app.core.exceptions import AppException
|
||
from backend.app.core.security import create_access_token, decode_access_token, verify_password
|
||
from backend.app.repositories.system_repository import SystemRepository
|
||
|
||
|
||
class AuthService:
|
||
"""认证服务,封装登录、登出、Token 验证和权限加载逻辑。
|
||
|
||
依赖:
|
||
SystemRepository - 用户、角色、菜单数据访问
|
||
security 模块 - 密码验证、JWT Token 生成/解析
|
||
settings - Token 存储路径等配置
|
||
|
||
类属性:
|
||
DEFAULT_ROLE_MENUS - 各角色的默认菜单配置(数据库无菜单数据时的兜底)
|
||
DEFAULT_ROLE_PERMISSIONS - 各角色的默认权限编码配置(兜底)
|
||
"""
|
||
|
||
DEFAULT_ROLE_MENUS = {
|
||
"salesman": [{"menu_name": "我的订单", "menu_path": "/orders"}],
|
||
"manager": [{"menu_name": "审批中心", "menu_path": "/pages/approve-list"}],
|
||
"driver": [{"menu_name": "我的任务", "menu_path": "/pages/task-list"}],
|
||
"admin": [{"menu_name": "后台首页", "menu_path": "/dashboard"}],
|
||
}
|
||
|
||
DEFAULT_ROLE_PERMISSIONS = {
|
||
"salesman": ["order:create", "order:list", "order:submit", "customer:list", "ai:parse-order"],
|
||
"manager": [
|
||
"order:list",
|
||
"order:approve",
|
||
"order:cancel-approve",
|
||
"logistics:task:list",
|
||
"logistics:task:create",
|
||
"logistics:trace:list",
|
||
"logistics:trace:create",
|
||
"report:performance:view",
|
||
"report:performance:export",
|
||
"ai:recognize",
|
||
"ai:correct",
|
||
"master-data:list",
|
||
"master-data:update",
|
||
],
|
||
"driver": [
|
||
"driver:task:list",
|
||
"driver:task:accept",
|
||
"driver:task:pickup",
|
||
"driver:task:deliver",
|
||
"logistics:trace:list",
|
||
],
|
||
"admin": [
|
||
"system:view",
|
||
"system:user:list",
|
||
"system:user:create",
|
||
"system:user:update",
|
||
"system:user:reset-password",
|
||
"system:role:list",
|
||
"system:role:create",
|
||
"system:role:update",
|
||
"system:role:assign-menus",
|
||
"system:menu:list",
|
||
"system:menu:create",
|
||
"system:menu:update",
|
||
"report:performance:view",
|
||
"report:performance:export",
|
||
"logistics:task:list",
|
||
"logistics:task:create",
|
||
"logistics:task:cancel",
|
||
"logistics:trace:list",
|
||
"logistics:trace:create",
|
||
"ai:recognize",
|
||
"ai:correct",
|
||
"ai:parse-order",
|
||
"order:list",
|
||
"order:create",
|
||
"order:update",
|
||
"order:submit",
|
||
"order:approve",
|
||
"order:cancel-approve",
|
||
"order:supplier-text",
|
||
"customer:list",
|
||
"customer:create",
|
||
"customer:update",
|
||
"master-data:list",
|
||
"master-data:update",
|
||
],
|
||
}
|
||
|
||
def __init__(self) -> None:
|
||
self.repository = SystemRepository()
|
||
self.settings = get_settings()
|
||
self.token_store_path = Path(self.settings.auth_token_store_path)
|
||
self.token_store_path.parent.mkdir(parents=True, exist_ok=True)
|
||
self.revoked_tokens = self._load_revoked_tokens()
|
||
|
||
def login(self, username: str, password: str, role_type: str, session: Session | None = None) -> dict | None:
|
||
"""用户登录。
|
||
|
||
验证用户名密码、校验角色匹配和账号状态,生成 JWT Token,
|
||
加载菜单和权限信息。
|
||
|
||
Args:
|
||
username: 用户名
|
||
password: 密码(明文)
|
||
role_type: 登录的角色类型(salesman/manager/driver/admin)
|
||
session: 数据库会话
|
||
|
||
Returns:
|
||
用户信息字典(含 token、menus、permissions),登录失败抛出异常
|
||
|
||
被调用路由: auth.py - POST /auth/login
|
||
"""
|
||
if session is not None:
|
||
try:
|
||
user = self.repository.get_user_by_username(session, username)
|
||
if user is None or not verify_password(password, user.password_hash):
|
||
raise AppException(
|
||
code=ErrorCode.UNAUTHORIZED,
|
||
message="账号不存在或密码错误",
|
||
status_code=401,
|
||
)
|
||
|
||
role = self.repository.get_role(session, user.role_id)
|
||
if role is None:
|
||
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在", status_code=404)
|
||
if user.status != 1:
|
||
raise AppException(code=ErrorCode.FORBIDDEN, message="账号已停用", status_code=403)
|
||
if role.status != 1 or (role_type and role.role_code != role_type):
|
||
raise AppException(code=ErrorCode.FORBIDDEN, message="登录角色与账号不匹配", status_code=403)
|
||
|
||
return self._build_profile(session, user, role, include_token=True)
|
||
except AppException:
|
||
raise
|
||
except SQLAlchemyError as exc:
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||
|
||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||
|
||
def get_me(self, token: str, session: Session | None = None) -> dict | None:
|
||
"""根据 Token 获取当前用户信息(无需重新登录)。
|
||
|
||
解析 JWT Token 获取用户 ID 和角色,验证 Token 未被吊销,
|
||
重新加载最新的用户信息和权限。
|
||
|
||
Args:
|
||
token: JWT Token 字符串
|
||
session: 数据库会话
|
||
|
||
Returns:
|
||
用户信息字典(不含 token),验证失败返回 None
|
||
|
||
被调用路由: deps.py - get_current_user 依赖注入
|
||
"""
|
||
if self._is_token_revoked(token):
|
||
return None
|
||
|
||
if session is not None:
|
||
try:
|
||
payload = decode_access_token(token)
|
||
if payload is not None:
|
||
user_id = int(payload["user_id"])
|
||
role_code = str(payload["role_code"])
|
||
user = self.repository.get_user(session, user_id)
|
||
if user is None or user.status != 1:
|
||
return None
|
||
role = self.repository.get_role(session, user.role_id)
|
||
if role is None or role.status != 1 or role.role_code != role_code:
|
||
return None
|
||
return self._build_profile(session, user, role, include_token=False)
|
||
except (SQLAlchemyError, ValueError, KeyError, TypeError):
|
||
pass
|
||
|
||
return None
|
||
|
||
def logout(self, token: str) -> dict:
|
||
"""用户登出。
|
||
|
||
将当前 Token 加入吊销列表并持久化到本地文件,确保跨进程生效。
|
||
|
||
Args:
|
||
token: 当前 JWT Token
|
||
|
||
Returns:
|
||
{"success": True}
|
||
|
||
被调用路由: auth.py - POST /auth/logout
|
||
"""
|
||
if token:
|
||
# token 失效需要跨进程持久化,避免服务重启后已退出的 token 又重新可用。
|
||
self._revoke_token(token)
|
||
return {"success": True}
|
||
|
||
def _build_profile(self, session: Session, user, role, include_token: bool) -> dict:
|
||
"""构建用户信息字典(含菜单和权限)。
|
||
|
||
Args:
|
||
session: 数据库会话
|
||
user: 用户 ORM 对象
|
||
role: 角色 ORM 对象
|
||
include_token: 是否生成 JWT Token(登录时为 True,get_me 时为 False)
|
||
|
||
Returns:
|
||
用户信息字典
|
||
"""
|
||
menus = self._load_role_menus(session, role.role_code)
|
||
permissions = self._load_role_permissions(session, role.role_code)
|
||
return {
|
||
"user_id": user.id,
|
||
"username": user.username,
|
||
"real_name": user.real_name,
|
||
"mobile": user.mobile,
|
||
"role_id": role.id,
|
||
"role_name": role.role_name,
|
||
"role_code": role.role_code,
|
||
"token": create_access_token({"user_id": user.id, "role_code": role.role_code}) if include_token else None,
|
||
"menus": menus,
|
||
"permissions": permissions,
|
||
}
|
||
|
||
def _load_role_menus(self, session: Session, role_code: str) -> list[dict]:
|
||
"""加载角色对应的菜单列表。
|
||
|
||
admin 角色返回所有菜单,其他角色通过 role_menu 关系表过滤。
|
||
若数据库无菜单数据则使用 DEFAULT_ROLE_MENUS 兜底。
|
||
|
||
Args:
|
||
session: 数据库会话
|
||
role_code: 角色编码
|
||
|
||
Returns:
|
||
菜单列表,每项包含 menu_name 和 menu_path
|
||
"""
|
||
menus = self.repository.list_menus(session, {"status": 1})
|
||
if not menus:
|
||
return self.DEFAULT_ROLE_MENUS.get(role_code, [])
|
||
|
||
if role_code == "admin":
|
||
return [{"menu_name": menu.menu_name, "menu_path": menu.menu_path} for menu in menus]
|
||
|
||
role = self.repository.get_role_by_code(session, role_code)
|
||
if role is not None:
|
||
relations = self.repository.list_role_menu_relations(session, role.id)
|
||
if relations:
|
||
allowed_menu_ids = {item.menu_id for item in relations}
|
||
filtered = [
|
||
{"menu_name": menu.menu_name, "menu_path": menu.menu_path}
|
||
for menu in menus
|
||
if menu.id in allowed_menu_ids and menu.menu_path
|
||
]
|
||
if filtered:
|
||
return filtered
|
||
|
||
allowed_paths = {item["menu_path"] for item in self.DEFAULT_ROLE_MENUS.get(role_code, [])}
|
||
filtered = [
|
||
{"menu_name": menu.menu_name, "menu_path": menu.menu_path}
|
||
for menu in menus
|
||
if menu.menu_path in allowed_paths
|
||
]
|
||
return filtered or self.DEFAULT_ROLE_MENUS.get(role_code, [])
|
||
|
||
def _load_role_permissions(self, session: Session, role_code: str) -> list[str]:
|
||
"""加载角色对应的权限编码列表。
|
||
|
||
admin 角色返回所有权限,其他角色通过 role_menu 关系表过滤。
|
||
若数据库无菜单数据则使用 DEFAULT_ROLE_PERMISSIONS 兜底。
|
||
|
||
Args:
|
||
session: 数据库会话
|
||
role_code: 角色编码
|
||
|
||
Returns:
|
||
权限编码字符串列表
|
||
"""
|
||
menus = self.repository.list_menus(session, {"status": 1})
|
||
if not menus:
|
||
return self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
|
||
|
||
if role_code == "admin":
|
||
permissions = [menu.permission_code for menu in menus if menu.permission_code]
|
||
default_permissions = self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
|
||
merged = list(dict.fromkeys([*default_permissions, *permissions]))
|
||
return merged or default_permissions
|
||
|
||
role = self.repository.get_role_by_code(session, role_code)
|
||
if role is not None:
|
||
relations = self.repository.list_role_menu_relations(session, role.id)
|
||
if relations:
|
||
allowed_menu_ids = {item.menu_id for item in relations}
|
||
permissions = [
|
||
menu.permission_code
|
||
for menu in menus
|
||
if menu.id in allowed_menu_ids and menu.permission_code
|
||
]
|
||
if permissions:
|
||
return permissions
|
||
|
||
allowed = set(self.DEFAULT_ROLE_PERMISSIONS.get(role_code, []))
|
||
permissions = [menu.permission_code for menu in menus if menu.permission_code in allowed]
|
||
return permissions or self.DEFAULT_ROLE_PERMISSIONS.get(role_code, [])
|
||
|
||
def _load_revoked_tokens(self) -> dict[str, int]:
|
||
"""从本地文件加载已吊销的 Token 列表。
|
||
|
||
自动清理已过期的 Token 条目。
|
||
|
||
Returns:
|
||
Token 到过期时间戳的映射字典
|
||
"""
|
||
if not self.token_store_path.exists():
|
||
return {}
|
||
try:
|
||
payload = json.loads(self.token_store_path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError):
|
||
return {}
|
||
now_ts = int(datetime.now(timezone.utc).timestamp())
|
||
tokens = {
|
||
token: int(exp)
|
||
for token, exp in payload.items()
|
||
if isinstance(token, str) and isinstance(exp, int) and exp > now_ts
|
||
}
|
||
if len(tokens) != len(payload):
|
||
self._save_revoked_tokens(tokens)
|
||
return tokens
|
||
|
||
def _save_revoked_tokens(self, tokens: dict[str, int]) -> None:
|
||
"""将已吊销的 Token 列表持久化到本地文件。
|
||
|
||
Args:
|
||
tokens: Token 到过期时间戳的映射字典
|
||
"""
|
||
self.token_store_path.write_text(json.dumps(tokens, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
def _is_token_revoked(self, token: str) -> bool:
|
||
"""检查 Token 是否已被吊销。
|
||
|
||
同时清理已过期的吊销条目。
|
||
|
||
Args:
|
||
token: JWT Token 字符串
|
||
|
||
Returns:
|
||
True 表示已吊销
|
||
"""
|
||
if not token:
|
||
return False
|
||
now_ts = int(datetime.now(timezone.utc).timestamp())
|
||
expired_tokens = [item for item, exp in self.revoked_tokens.items() if exp <= now_ts]
|
||
if expired_tokens:
|
||
for item in expired_tokens:
|
||
self.revoked_tokens.pop(item, None)
|
||
self._save_revoked_tokens(self.revoked_tokens)
|
||
return token in self.revoked_tokens
|
||
|
||
def _revoke_token(self, token: str) -> None:
|
||
"""吊销指定 Token 并持久化。
|
||
|
||
解析 Token 的过期时间,存入吊销列表并写入本地文件。
|
||
|
||
Args:
|
||
token: 需要吊销的 JWT Token
|
||
"""
|
||
payload = decode_access_token(token)
|
||
expire_at = int(payload.get("exp", 0)) if payload else int(datetime.now(timezone.utc).timestamp()) + 60
|
||
self.revoked_tokens[token] = expire_at
|
||
self._save_revoked_tokens(self.revoked_tokens)
|
||
|
||
|
||
auth_service = AuthService()
|