104 lines
3.6 KiB
Python
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
|