Files
2026-10-15 20:36:59 +08:00

104 lines
3.6 KiB
Python

"""FastAPI 依赖注入:DB / Redis / 当前身份(用户或游客)/ 管理员"""
from dataclasses import dataclass
import redis.asyncio as aioredis
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import errors
from app.core.response import BizException
from app.core.security import decode_token
from app.database import get_db
from app.redis_client import get_redis
TOKEN_TYPE_ACCESS = "access"
@dataclass
class Identity:
"""请求身份:登录用户(user_id 非空)或游客(device_id 非空)"""
user_id: int | None
device_id: str | None
@property
def is_user(self) -> bool:
return self.user_id is not None
@property
def is_guest(self) -> bool:
return self.user_id is None and self.device_id is not None
@property
def subject(self) -> str:
"""计费主体标识:注册用户=user_id,游客=device_id"""
return str(self.user_id) if self.user_id is not None else (self.device_id or "")
async def get_identity(
authorization: str | None = Header(default=None),
x_device_id: str | None = Header(default=None),
redis: aioredis.Redis = Depends(get_redis),
) -> Identity:
"""解析顺序:Bearer Token 有效 → 用户态;否则取 X-Device-ID → 游客态"""
if authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ").strip()
try:
payload = decode_token(token)
except BizException:
payload = None
if payload and payload.get("type") == TOKEN_TYPE_ACCESS:
# 登出/改密的 jti 黑名单校验
jti = payload.get("jti", "")
if jti and await redis.exists(f"token:blacklist:{jti}"):
payload = None
else:
# 令牌纪元校验:改密/注销后旧 Token 全端失效
user_id = int(payload["sub"])
epoch = int(await redis.get(f"auth:epoch:{user_id}") or 0)
if int(payload.get("ep", 0)) != epoch:
payload = None
if payload:
return Identity(user_id=int(payload["sub"]), device_id=x_device_id)
return Identity(user_id=None, device_id=x_device_id)
async def require_user(identity: Identity = Depends(get_identity)) -> Identity:
"""仅登录用户可用"""
if identity.user_id is None:
raise BizException(errors.UNAUTHORIZED, http_status=401)
return identity
@dataclass
class AdminIdentity:
admin_id: int
role: str # super / reviewer / operator
async def get_admin(
authorization: str | None = Header(default=None),
redis: aioredis.Redis = Depends(get_redis),
) -> AdminIdentity:
if not authorization or not authorization.startswith("Bearer "):
raise BizException(errors.UNAUTHORIZED, http_status=401)
payload = decode_token(authorization.removeprefix("Bearer ").strip())
token_type = payload.get("type", "")
if not token_type.startswith("admin:"):
raise BizException(errors.ADMIN_FORBIDDEN, http_status=403)
jti = payload.get("jti", "")
if jti and await redis.exists(f"token:blacklist:{jti}"):
raise BizException(errors.UNAUTHORIZED, http_status=401)
return AdminIdentity(admin_id=int(payload["sub"]), role=token_type.split(":", 1)[1])
def require_roles(*roles: str):
"""后台角色鉴权:super 拥有全部权限"""
async def checker(admin: AdminIdentity = Depends(get_admin)) -> AdminIdentity:
if admin.role != "super" and admin.role not in roles:
raise BizException(errors.ADMIN_FORBIDDEN, http_status=403)
return admin
return checker