提交
This commit is contained in:
1 parent
d84f4b944c
commit
7c838cfb2d
249 files changed
+50600
-18556
No files matched your search
@@ -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
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# 七牛云存储配置 (生产环境推荐)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user