2026-10-09 06:46:36 -04:00
|
|
|
|
"""FastAPI 公共依赖注入。"""
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Annotated
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
from fastapi import Depends, Request
|
2026-10-09 06:46:36 -04:00
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-10-09 22:44:13 -04:00
|
|
|
|
from app.core.constants import ROLE_ADMIN
|
2026-10-09 06:46:36 -04:00
|
|
|
|
from app.core.db import get_db
|
2026-10-09 22:44:13 -04:00
|
|
|
|
from app.core.exceptions import AuthError, ForbiddenError
|
2026-10-09 06:46:36 -04:00
|
|
|
|
from app.core.security import decode_token
|
|
|
|
|
|
from app.models import User
|
|
|
|
|
|
from app.repositories import user_repository
|
|
|
|
|
|
from app.services import token_blacklist
|
2026-10-10 04:28:04 -04:00
|
|
|
|
from app.services.audit_service import RequestMeta
|
2026-10-09 06:46:36 -04:00
|
|
|
|
|
|
|
|
|
|
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
|
|
|
|
|
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
|
|
|
|
|
_bearer_scheme = HTTPBearer(auto_error=False, description="Bearer 访问令牌")
|
|
|
|
|
|
|
|
|
|
|
|
BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)]
|
|
|
|
|
|
DbSession = Annotated[Session, Depends(get_db)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-10 04:28:04 -04:00
|
|
|
|
def client_ip(request: Request) -> str | None:
|
|
|
|
|
|
"""取客户端 IP。
|
|
|
|
|
|
|
|
|
|
|
|
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
|
|
|
|
|
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
|
|
|
|
|
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return request.client.host if request.client else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_request_meta(request: Request) -> RequestMeta:
|
|
|
|
|
|
"""提取 HTTP 请求元信息,供操作日志审计使用。"""
|
|
|
|
|
|
return RequestMeta(
|
|
|
|
|
|
ip=client_ip(request),
|
|
|
|
|
|
user_agent=request.headers.get("user-agent"),
|
|
|
|
|
|
method=request.method,
|
|
|
|
|
|
path=request.url.path,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RequestMetaDep = Annotated[RequestMeta, Depends(get_request_meta)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-09 06:46:36 -04:00
|
|
|
|
def get_raw_token(credentials: BearerCredentials) -> str:
|
|
|
|
|
|
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
|
|
|
|
|
if credentials is None or not credentials.credentials:
|
|
|
|
|
|
raise AuthError()
|
|
|
|
|
|
return credentials.credentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_current_user(credentials: BearerCredentials, session: DbSession) -> User:
|
|
|
|
|
|
"""从 Bearer 令牌解析当前登录用户。
|
|
|
|
|
|
|
|
|
|
|
|
**三重校验,缺一不可**:
|
|
|
|
|
|
|
|
|
|
|
|
1. 签名与有效期(`decode_token`)
|
|
|
|
|
|
2. 是否已被登出拉黑(Redis `ys:token:blacklist:{jti}`)
|
|
|
|
|
|
3. **令牌版本与库中是否一致** —— 禁用 / 改密 / 强制下线会把
|
|
|
|
|
|
`users.token_version` 加一,使此前签发的所有令牌立即失效。
|
|
|
|
|
|
这一步**不依赖 Redis**,因此 Redis 故障或数据被清空时,
|
|
|
|
|
|
离职员工依然无法继续访问
|
|
|
|
|
|
|
|
|
|
|
|
Redis 不可用时第 2 步会抛异常而非放行 —— **有意的 fail-closed**:
|
|
|
|
|
|
对能查看全员薪酬的系统,宁可拒绝请求也不能放行状态不明的令牌。
|
|
|
|
|
|
|
|
|
|
|
|
:raises AuthError: 任一项校验未通过(401)
|
|
|
|
|
|
"""
|
|
|
|
|
|
if credentials is None or not credentials.credentials:
|
|
|
|
|
|
raise AuthError()
|
|
|
|
|
|
|
|
|
|
|
|
payload = decode_token(credentials.credentials)
|
|
|
|
|
|
|
|
|
|
|
|
jti = payload.get("jti")
|
|
|
|
|
|
if jti and token_blacklist.is_revoked(jti):
|
|
|
|
|
|
raise AuthError("登录状态已失效,请重新登录")
|
|
|
|
|
|
|
|
|
|
|
|
subject = payload.get("sub")
|
|
|
|
|
|
user = user_repository.get_by_id(session, int(subject)) if _is_digits(subject) else None
|
|
|
|
|
|
if user is None or user.status != 1:
|
|
|
|
|
|
raise AuthError()
|
|
|
|
|
|
|
|
|
|
|
|
if payload.get("ver") != user.token_version:
|
|
|
|
|
|
raise AuthError("登录状态已失效,请重新登录")
|
|
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_digits(value: object) -> bool:
|
|
|
|
|
|
"""判断令牌中的 sub 是否为合法的主键字符串。
|
|
|
|
|
|
|
|
|
|
|
|
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return isinstance(value, str) and value.isdigit()
|
2026-10-09 22:44:13 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def require_admin(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
2026-10-09 22:44:13 -04:00
|
|
|
|
"""要求当前用户具备管理员角色。
|
|
|
|
|
|
|
|
|
|
|
|
需求 2.5.1 只定义了「系统管理员(全部权限)」与「普通用户(业务操作)」两个
|
|
|
|
|
|
角色,故权限判定用**角色粗粒度**即可,不必引入权限码表 —— `permissions`
|
|
|
|
|
|
与 `role_permissions` 表已建好,等业务方给出权限清单后再启用。
|
|
|
|
|
|
|
|
|
|
|
|
:raises ForbiddenError: 已登录但不具备管理员角色(403)
|
|
|
|
|
|
"""
|
2026-11-02 22:20:55 -05:00
|
|
|
|
# 权限码与「仅管理员」是两套语义:部分系统权限(如 system:params:update)
|
|
|
|
|
|
# 可授予普通角色,统一入口通过不代表本端点放行,这里不依赖
|
|
|
|
|
|
# authorization_checked 短路,每次按数据库角色重新判定。
|
2026-10-09 22:44:13 -04:00
|
|
|
|
if ROLE_ADMIN not in user_repository.get_role_codes(current_user):
|
|
|
|
|
|
raise ForbiddenError("需要管理员权限")
|
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
AdminUser = Annotated[User, Depends(require_admin)]
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
2026-10-18 23:19:16 -04:00
|
|
|
|
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
|
|
|
|
|
|
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
|
|
|
|
|
|
LoginUser = Annotated[User, Depends(get_current_user)]
|
|
|
|
|
|
|
2026-10-15 06:50:38 -04:00
|
|
|
|
|
2026-11-01 20:42:53 -05:00
|
|
|
|
def require_payroll_writer(current_user: LoginUser) -> User:
|
2026-11-02 22:20:55 -05:00
|
|
|
|
"""兼容原写接口注解;操作权限由统一入口校验,制作人限制仅用于工资操作。"""
|
|
|
|
|
|
return current_user
|
2026-11-01 20:42:53 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PayrollWriter = Annotated[User, Depends(require_payroll_writer)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_workbench_reader(current_user: LoginUser) -> User:
|
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
WorkbenchReader = Annotated[User, Depends(require_workbench_reader)]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 04:33:31 -04:00
|
|
|
|
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
|
2026-10-15 06:50:38 -04:00
|
|
|
|
"""要求当前用户具备任一指定角色(依赖工厂)。
|
|
|
|
|
|
|
|
|
|
|
|
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
|
|
|
|
|
|
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
|
|
|
|
|
|
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
|
|
|
|
|
|
|
2026-10-19 04:33:31 -04:00
|
|
|
|
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
|
|
|
|
|
|
避免既有调用与测试变动)。
|
|
|
|
|
|
|
2026-10-15 06:50:38 -04:00
|
|
|
|
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-11-02 22:20:55 -05:00
|
|
|
|
def _checker(current_user: Annotated[User, Depends(get_current_user)], request: Request) -> User:
|
|
|
|
|
|
# 与 require_admin 同理:不依赖统一入口的短路标记,按数据库角色判定。
|
|
|
|
|
|
held = set(user_repository.get_role_codes(current_user))
|
|
|
|
|
|
if not held & set(role_codes):
|
2026-10-19 04:33:31 -04:00
|
|
|
|
raise ForbiddenError(message)
|
2026-10-15 06:50:38 -04:00
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
return _checker
|
2026-11-02 22:20:55 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def authorize_request(request: Request, session: DbSession, credentials: BearerCredentials):
|
|
|
|
|
|
"""统一覆盖查询、导出和写入;公开端点以显式白名单处理。"""
|
|
|
|
|
|
from app.services import authorization_policy as policy
|
|
|
|
|
|
from app.services.authorization_service import require_permission
|
|
|
|
|
|
|
|
|
|
|
|
endpoint = request.scope["route"].endpoint
|
|
|
|
|
|
module = endpoint.__module__.removeprefix("app.api.v1.")
|
|
|
|
|
|
name = endpoint.__name__
|
|
|
|
|
|
if (module, name) in policy.PUBLIC_ENDPOINTS:
|
|
|
|
|
|
return
|
|
|
|
|
|
user = get_current_user(credentials, session)
|
|
|
|
|
|
body = {}
|
|
|
|
|
|
if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").split(";", 1)[0] == "application/json":
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = await request.json()
|
|
|
|
|
|
body = value if isinstance(value, dict) else {}
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
body = {}
|
|
|
|
|
|
code = policy.permission_for(module, name, request.method, params=request.path_params, body=body)
|
|
|
|
|
|
if code is None:
|
|
|
|
|
|
raise ForbiddenError("此接口尚未登记权限,操作已拒绝")
|
|
|
|
|
|
if code:
|
|
|
|
|
|
try:
|
|
|
|
|
|
require_permission(session, user, code)
|
|
|
|
|
|
except ForbiddenError:
|
|
|
|
|
|
from app.services.audit_service import AuditRecord, audit
|
|
|
|
|
|
|
|
|
|
|
|
with audit(session, operator=user, meta=get_request_meta(request), record=AuditRecord(
|
|
|
|
|
|
"权限管理", "权限拒绝", "操作因未授权被拒绝", {"permission_code": code}
|
|
|
|
|
|
)):
|
|
|
|
|
|
raise
|
|
|
|
|
|
request.state.authorization_checked = True
|