This commit is contained in:
zlwu committed 2026-06-30 23:44:22 +08:00
1 parent d84f4b944c
commit 7c838cfb2d
249 files changed
+50600 -18556

No files matched your search

+6 -16
View File
@@ -17,11 +17,11 @@ from starlette.responses import Response
from app.core import api_crypto
from app.core.config import settings
from app.core.redis_client import get_redis_client
from app.core.response import error_response
logger = logging.getLogger("sqxp")
_redis_client: Optional[redis.Redis] = None
_local_nonce_cache: dict[str, float] = {}
@@ -59,20 +59,7 @@ def _claims_from_auth(auth_header: Optional[str]) -> Tuple[Optional[str], Option
def _get_redis_client() -> Optional[redis.Redis]:
global _redis_client
if not settings.REDIS_HOST:
return None
if _redis_client is None:
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
password=settings.REDIS_PASSWORD or None,
socket_timeout=settings.REDIS_TIMEOUT_SEC,
ssl=settings.REDIS_SSL_ENABLED,
decode_responses=True,
)
return _redis_client
return get_redis_client()
async def _mark_nonce_once(user_id: str, nonce: str) -> bool:
@@ -81,7 +68,10 @@ async def _mark_nonce_once(user_id: str, nonce: str) -> bool:
client = _get_redis_client()
if client is not None:
try:
ok = await asyncio.wait_for(client.set(key, "1", ex=ttl_sec, nx=True), timeout=2.0)
ok = await asyncio.wait_for(
client.set(key, "1", ex=ttl_sec, nx=True),
timeout=settings.REDIS_OP_TIMEOUT_SEC,
)
return bool(ok)
except (asyncio.TimeoutError, redis_exceptions.RedisError):
logger.warning("api crypto nonce redis unavailable, fallback to local memory")
+45 -87
View File
@@ -1,9 +1,7 @@
"""认证模块"""
import asyncio
from datetime import datetime, timedelta
from typing import Optional
import redis.asyncio as redis
from redis import exceptions as redis_exceptions
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
@@ -14,8 +12,8 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.redis_client import get_sync_redis_client
from app.crud.user import user_crud
from app.core.utils import generate_uuid
from app.services.web_auth_roles import resolve_web_role_for_user
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/public/login")
@@ -23,30 +21,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/public/login")
REFRESH_TOKEN_EXPIRE_DAYS = 7
REFRESH_TOKEN_EXPIRE_SECONDS = int(timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS).total_seconds())
_redis_client: Optional[redis.Redis] = None
def _redis_session_enabled() -> bool:
# 只要配置了 REDIS_HOST,就启用“会话校验/refreshToken 吊销”
return bool(settings.REDIS_HOST)
def _get_redis_client() -> Optional[redis.Redis]:
global _redis_client
if not _redis_session_enabled():
return None
if _redis_client is None:
_redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
password=settings.REDIS_PASSWORD or None,
socket_timeout=settings.REDIS_TIMEOUT_SEC,
ssl=settings.REDIS_SSL_ENABLED,
decode_responses=True,
)
return _redis_client
def _session_key(session_id: str) -> str:
return f"auth:sessions:{session_id}"
@@ -56,78 +30,66 @@ def _refresh_key(refresh_jti: str) -> str:
return f"auth:refresh:{refresh_jti}"
async def store_session(session_id: str, user_id: str) -> None:
def _redis_call_safe(fn):
"""同步 Redis 调用;失败时返回 None 供调用方降级。"""
r = get_sync_redis_client()
if r is None:
return None
try:
return fn(r)
except (redis_exceptions.TimeoutError, redis_exceptions.ConnectionError, redis_exceptions.RedisError):
return None
def store_session(session_id: str, user_id: str) -> None:
"""写入会话(用于强制失效)"""
r = _get_redis_client()
if r is None:
return
await r.set(_session_key(session_id), user_id, ex=REFRESH_TOKEN_EXPIRE_SECONDS)
_redis_call_safe(lambda r: r.set(_session_key(session_id), user_id, ex=REFRESH_TOKEN_EXPIRE_SECONDS))
async def revoke_session(session_id: str) -> None:
def revoke_session(session_id: str) -> None:
"""撤销会话(登出/强制失效)"""
r = _get_redis_client()
if r is None:
return
await r.delete(_session_key(session_id))
_redis_call_safe(lambda r: r.delete(_session_key(session_id)))
async def extend_session(session_id: str) -> None:
"""刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)"""
def extend_session(session_id: str) -> None:
"""刷新会话 TTL(已登录用户每次 API 校验通过后滑动续期)"""
if not session_id:
return
r = _get_redis_client()
if r is None:
return
try:
await asyncio.wait_for(
r.expire(_session_key(session_id), REFRESH_TOKEN_EXPIRE_SECONDS),
timeout=3.0,
)
except (asyncio.TimeoutError, redis_exceptions.TimeoutError, redis_exceptions.ConnectionError):
pass
_redis_call_safe(lambda r: r.expire(_session_key(session_id), REFRESH_TOKEN_EXPIRE_SECONDS))
async def is_session_valid(session_id: str) -> bool:
def is_session_valid(session_id: str) -> bool:
"""检查会话是否仍然有效"""
r = _get_redis_client()
if r is None:
if get_sync_redis_client() is None:
return True
if not session_id:
return False
try:
# 添加连接超时保护,Redis 不可用时降级为验证通过
return await asyncio.wait_for(r.exists(_session_key(session_id)), timeout=3.0) > 0
except (asyncio.TimeoutError, redis_exceptions.TimeoutError, redis_exceptions.ConnectionError):
# Redis 连接失败时降级:只验证 JWT,不过滤会话吊销
exists = _redis_call_safe(lambda r: r.exists(_session_key(session_id)))
if exists is None:
return True
return exists > 0
async def store_refresh_token(refresh_jti: str, user_id: str) -> None:
def store_refresh_token(refresh_jti: str, user_id: str) -> None:
"""写入 refreshToken(用于 one-time 使用/吊销)"""
r = _get_redis_client()
if r is None:
return
await r.set(_refresh_key(refresh_jti), user_id, ex=REFRESH_TOKEN_EXPIRE_SECONDS)
_redis_call_safe(
lambda r: r.set(_refresh_key(refresh_jti), user_id, ex=REFRESH_TOKEN_EXPIRE_SECONDS)
)
async def is_refresh_token_valid(refresh_jti: str) -> bool:
r = _get_redis_client()
if r is None:
def is_refresh_token_valid(refresh_jti: str) -> bool:
if get_sync_redis_client() is None:
return True
if not refresh_jti:
return False
try:
return await asyncio.wait_for(r.exists(_refresh_key(refresh_jti)), timeout=3.0) > 0
except (asyncio.TimeoutError, redis_exceptions.TimeoutError, redis_exceptions.ConnectionError):
exists = _redis_call_safe(lambda r: r.exists(_refresh_key(refresh_jti)))
if exists is None:
return True
return exists > 0
async def revoke_refresh_token(refresh_jti: str) -> None:
r = _get_redis_client()
if r is None:
return
await r.delete(_refresh_key(refresh_jti))
def revoke_refresh_token(refresh_jti: str) -> None:
_redis_call_safe(lambda r: r.delete(_refresh_key(refresh_jti)))
def create_access_token(
@@ -157,7 +119,7 @@ def create_access_token(
def create_refresh_token(data: dict, session_id: str, refresh_jti: str) -> str:
"""创建刷新令牌"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) # 有效期与 Redis TTL 保持一致
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update(
{
"exp": expire,
@@ -183,24 +145,20 @@ def decode_token(token: str) -> dict:
)
async def get_current_user_by_token(
token: str,
db: Session,
) -> dict:
def get_current_user_by_token(token: str, db: Session) -> dict:
"""根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)"""
payload = decode_token(token)
user_id: str = payload.get("sub")
session_id: str = payload.get("sid")
if not await is_session_valid(session_id):
if not is_session_valid(session_id):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token无效或已过期",
)
# 方案 C:有操作时延长 Redis 会话,连续 7 天无请求则会话过期
await extend_session(session_id)
extend_session(session_id)
if user_id is None:
raise HTTPException(
@@ -245,15 +203,15 @@ async def get_current_user_by_token(
}
async def get_current_user(
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
) -> dict:
"""获取当前用户"""
return await get_current_user_by_token(token, db)
"""获取当前用户(sync 依赖,配合 sync 路由在线程池执行,避免阻塞事件循环)。"""
return get_current_user_by_token(token, db)
async def get_current_user_simple(token: str = Depends(oauth2_scheme)) -> dict:
def get_current_user_simple(token: str = Depends(oauth2_scheme)) -> dict:
"""简化版获取当前用户(不查数据库)"""
payload = decode_token(token)
user_id: str = payload.get("sub")
@@ -268,7 +226,7 @@ async def get_current_user_simple(token: str = Depends(oauth2_scheme)) -> dict:
"username": payload.get("username"),
"role": payload.get("role"),
"region_id": payload.get("region_id"),
"real_name": payload.get("real_name")
"real_name": payload.get("real_name"),
}
+15 -1
View File
@@ -14,6 +14,16 @@ class Settings(BaseSettings):
# 数据库配置
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/sanqiang"
# pymysql 连接/读写超时(秒);远程库或休眠唤醒后避免 SELECT 无限阻塞
DB_CONNECT_TIMEOUT_SEC: int = 10
DB_READ_TIMEOUT_SEC: int = 10
DB_WRITE_TIMEOUT_SEC: int = 10
# 连接池回收周期(秒),宜小于 MySQL wait_timeout / 防火墙 idle 超时
DB_POOL_RECYCLE_SEC: int = 600
DB_POOL_SIZE: int = 5
DB_MAX_OVERFLOW: int = 10
# 后台保活间隔(秒),远程 MySQL 空闲 TCP 被回收时主动 SELECT 1
DB_KEEPALIVE_INTERVAL_SEC: int = 240
# JWT配置
SECRET_KEY: str = "your-secret-key-change-in-production-please-use-random-string"
@@ -49,7 +59,11 @@ class Settings(BaseSettings):
REDIS_PORT: int = 6379
REDIS_DB: int = 0
REDIS_PASSWORD: str = ""
REDIS_TIMEOUT_SEC: int = 30
REDIS_TIMEOUT_SEC: int = 5
REDIS_CONNECT_TIMEOUT_SEC: int = 5
REDIS_HEALTH_CHECK_INTERVAL_SEC: int = 30
# asyncio 层 Redis 操作超时(秒),与会话校验/续期一致
REDIS_OP_TIMEOUT_SEC: int = 3
REDIS_SSL_ENABLED: bool = False
# 七牛云存储配置 (生产环境推荐)
+9 -1
View File
@@ -7,7 +7,15 @@ from app.core.config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
pool_recycle=settings.DB_POOL_RECYCLE_SEC,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_reset_on_return="rollback",
connect_args={
"connect_timeout": settings.DB_CONNECT_TIMEOUT_SEC,
"read_timeout": settings.DB_READ_TIMEOUT_SEC,
"write_timeout": settings.DB_WRITE_TIMEOUT_SEC,
},
echo=settings.DEBUG,
)
+2 -2
View File
@@ -58,7 +58,7 @@ def get_db_current_user(
)
async def get_db_current_user_optional(
def get_db_current_user_optional(
token: Optional[str] = Depends(optional_oauth2_scheme),
db: Session = Depends(get_db),
) -> DbUserContext | None:
@@ -66,7 +66,7 @@ async def get_db_current_user_optional(
if not token:
return None
try:
user = await get_current_user_by_token(token, db)
user = get_current_user_by_token(token, db)
except HTTPException:
return None
role = user.get("role")
+55
View File
@@ -0,0 +1,55 @@
"""共享 Redis 客户端(同步:会话鉴权;异步:API 加密 nonce)。"""
from __future__ import annotations
from typing import Optional
import redis
import redis.asyncio as aioredis
from app.core.config import settings
_sync_redis_client: Optional[redis.Redis] = None
_async_redis_client: Optional[aioredis.Redis] = None
def redis_enabled() -> bool:
return bool(settings.REDIS_HOST)
def _redis_common_kwargs() -> dict:
return {
"host": settings.REDIS_HOST,
"port": settings.REDIS_PORT,
"db": settings.REDIS_DB,
"password": settings.REDIS_PASSWORD or None,
"socket_timeout": settings.REDIS_TIMEOUT_SEC,
"socket_connect_timeout": settings.REDIS_CONNECT_TIMEOUT_SEC,
"ssl": settings.REDIS_SSL_ENABLED,
"decode_responses": True,
}
def get_sync_redis_client() -> Optional[redis.Redis]:
"""同步 Redis(auth 会话等,在线程池中调用)。"""
global _sync_redis_client
if not redis_enabled():
return None
if _sync_redis_client is None:
_sync_redis_client = redis.Redis(
**_redis_common_kwargs(),
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL_SEC,
)
return _sync_redis_client
def get_redis_client() -> Optional[aioredis.Redis]:
"""异步 Redis(API 加密 nonce 等中间件)。"""
global _async_redis_client
if not redis_enabled():
return None
if _async_redis_client is None:
_async_redis_client = aioredis.Redis(
**_redis_common_kwargs(),
health_check_interval=settings.REDIS_HEALTH_CHECK_INTERVAL_SEC,
)
return _async_redis_client