Files
kplam bdd30f0a88 fix: 修复会话鉴权「登录后 token 无效」并补齐 NAS 部署流程
- deps.py: _create_redis_client 增加同步 ping 校验,Redis 不可达时正确降级为内存模式
- main.py: lifespan 复用 deps 的 UserStore 单例,避免 admin 与 API 请求实例不一致
- 前端: 强制改密弹窗(must_change_password 用户)、兼容新旧登录返回格式、markPasswordChanged
- 新增 NAS SSH 部署脚本与批处理测试;gitignore 前端构建产物
2026-08-04 11:38:28 +08:00

109 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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