bdd30f0a88
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式 - main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致 - 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged - 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
109 lines
4.2 KiB
Python
109 lines
4.2 KiB
Python
"""API 鉴权依赖:Bearer token → session → 用户记录
|
||
|
||
UserStore/SessionStore 为模块级懒加载单例:Redis 客户端创建失败时构造传 None,
|
||
降级为进程级内存存储(仅开发/兜底,重启失效)。存储后端读写异常统一转 2001。
|
||
测试可 monkeypatch _user_store/_session_store 单例完成注入。
|
||
"""
|
||
|
||
import structlog
|
||
from fastapi import Depends, Header
|
||
from redis import asyncio as redis_async
|
||
from redis import Redis as RedisSync
|
||
|
||
from app.api.response import ApiError
|
||
from app.config import settings
|
||
from app.core.sessions import SessionStore, SessionStoreError
|
||
from app.core.users import UserRecord, UserStore, UserStoreError
|
||
|
||
logger = structlog.get_logger()
|
||
|
||
# 模块级懒加载单例
|
||
_user_store: UserStore | None = None
|
||
_session_store: SessionStore | None = None
|
||
|
||
|
||
def _create_redis_client() -> redis_async.Redis | None:
|
||
"""创建 redis.asyncio 客户端(decode_responses=True);连接失败返回 None 走内存降级
|
||
|
||
注意:redis.asyncio.from_url() 仅构造客户端对象,不会实际连接 Redis,
|
||
因此需要用同步客户端 ping 一次确认连接可用,否则后续操作才会报错,
|
||
导致 UserStore/SessionStore 无法降级为内存模式。
|
||
"""
|
||
try:
|
||
# 同步 ping 确认 Redis 可达(from_url 不会实际连接)
|
||
sync_client = RedisSync.from_url(settings.redis_url, decode_responses=True)
|
||
sync_client.ping()
|
||
sync_client.close()
|
||
return redis_async.from_url(settings.redis_url, decode_responses=True)
|
||
except Exception:
|
||
logger.warning("Redis 连接失败,认证存储降级为内存模式", exc_info=True)
|
||
return None
|
||
|
||
|
||
def _get_user_store() -> UserStore:
|
||
"""用户存储懒加载单例"""
|
||
global _user_store
|
||
if _user_store is None:
|
||
_user_store = UserStore(_create_redis_client())
|
||
return _user_store
|
||
|
||
|
||
def _get_session_store() -> SessionStore:
|
||
"""会话存储懒加载单例"""
|
||
global _session_store
|
||
if _session_store is None:
|
||
_session_store = SessionStore(_create_redis_client())
|
||
return _session_store
|
||
|
||
|
||
def _parse_bearer(authorization: str | None) -> str | None:
|
||
"""解析 Authorization 头中的 Bearer token;缺失或格式非法返回 None"""
|
||
if not authorization:
|
||
return None
|
||
scheme, _, token = authorization.partition(" ")
|
||
if scheme.lower() != "bearer" or not token.strip():
|
||
return None
|
||
return token.strip()
|
||
|
||
|
||
async def _resolve_user(authorization: str | None) -> tuple[UserRecord, str]:
|
||
"""Bearer token → session → 用户记录(不拦截 must_change_password)
|
||
|
||
凭证缺失/无效/用户不存在抛 ApiError(1005);存储后端异常抛 ApiError(2001)。
|
||
供 get_current_user 与改密/退出端点共用(后者须对 must_change_password 用户放行)。
|
||
"""
|
||
token = _parse_bearer(authorization)
|
||
if token is None:
|
||
raise ApiError(1005, "未认证或凭证无效")
|
||
try:
|
||
session = await _get_session_store().get(token)
|
||
if session is None:
|
||
raise ApiError(1005, "未认证或凭证无效")
|
||
user = await _get_user_store().get(session["username"])
|
||
except (SessionStoreError, UserStoreError) as exc:
|
||
raise ApiError(2001, "认证服务暂不可用") from exc
|
||
if user is None:
|
||
raise ApiError(1005, "未认证或凭证无效")
|
||
return user, token
|
||
|
||
|
||
async def get_current_user(authorization: str | None = Header(None)) -> UserRecord:
|
||
"""鉴权依赖:校验 Bearer token 并返回当前用户记录
|
||
|
||
禁用用户被拦截(1005);must_change_password 用户被拦截(1006),须先经 POST /auth/password 改密。
|
||
注:logout/password 端点经 _resolve_user 自解析,不在此拦截范围内(改密是已登录用户唯一可用接口)。
|
||
"""
|
||
user, _ = await _resolve_user(authorization)
|
||
if not user.enabled:
|
||
raise ApiError(1005, "账号已禁用")
|
||
if user.must_change_password:
|
||
raise ApiError(1006, "首次登录须先修改密码")
|
||
return user
|
||
|
||
|
||
async def require_admin(user: UserRecord = Depends(get_current_user)) -> UserRecord:
|
||
"""鉴权依赖:在 get_current_user 之上要求 admin 角色"""
|
||
if user.role != "admin":
|
||
raise ApiError(1006, "权限不足")
|
||
return user
|