Files
MuMuAINovel/backend/app/api/auth.py
T
xiamuceer 1cde345ed9 1.优化AI请求替换OpenAI SDK调用,使用httpx和自定义头请求,避免触发部分公益站的cloudflare
2.修复deepseek模型调用问题,舍弃思考过程AI响应内容,只获取结果内容
3.新增会话过期机制,更新后添加到.env中
4.支持用户在生成章节内容时设置字数
2025-11-03 15:28:51 +08:00

340 lines
11 KiB
Python
Raw 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 - LinuxDO OAuth2 登录 + 本地账户登录
"""
from fastapi import APIRouter, HTTPException, Response, Request
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from typing import Optional
import hashlib
from datetime import datetime, timedelta, timezone
from app.services.oauth_service import LinuxDOOAuthService
from app.user_manager import user_manager
from app.database import init_db
from app.logger import get_logger
from app.config import settings
# 中国时区 UTC+8
CHINA_TZ = timezone(timedelta(hours=8))
def get_china_now():
"""获取中国当前时间"""
return datetime.now(CHINA_TZ)
logger = get_logger(__name__)
router = APIRouter(prefix="/auth", tags=["认证"])
# OAuth2 服务实例
oauth_service = LinuxDOOAuthService()
# State 临时存储(生产环境应使用 Redis)
_state_storage = {}
class AuthUrlResponse(BaseModel):
auth_url: str
state: str
class LocalLoginRequest(BaseModel):
"""本地登录请求"""
username: str
password: str
class LocalLoginResponse(BaseModel):
"""本地登录响应"""
success: bool
message: str
user: Optional[dict] = None
@router.get("/config")
async def get_auth_config():
"""获取认证配置信息"""
return {
"local_auth_enabled": settings.LOCAL_AUTH_ENABLED,
"linuxdo_enabled": bool(settings.LINUXDO_CLIENT_ID and settings.LINUXDO_CLIENT_SECRET)
}
@router.post("/local/login", response_model=LocalLoginResponse)
async def local_login(request: LocalLoginRequest, response: Response):
"""本地账户登录"""
# 检查是否启用本地登录
if not settings.LOCAL_AUTH_ENABLED:
raise HTTPException(status_code=403, detail="本地账户登录未启用")
# 检查是否配置了本地账户
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=500, detail="本地账户未配置")
# 验证用户名和密码
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 生成本地用户ID(使用用户名的hash)
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
# 创建或更新本地用户
user = await user_manager.create_or_update_from_linuxdo(
linuxdo_id=user_id,
username=request.username,
display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
avatar_url=None,
trust_level=9 # 本地用户给予高信任级别
)
# 初始化用户数据库
try:
await init_db(user.user_id)
logger.info(f"本地用户 {user.user_id} 数据库初始化成功")
except Exception as e:
logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}")
# 设置 Cookie2小时有效)
max_age = settings.SESSION_EXPIRE_MINUTES * 60
response.set_cookie(
key="user_id",
value=user.user_id,
max_age=max_age,
httponly=True,
samesite="lax"
)
# 设置过期时间戳 Cookie(用于前端判断)
china_now = get_china_now()
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
expire_at = int(expire_time.timestamp())
logger.info(f"✅ [登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟")
response.set_cookie(
key="session_expire_at",
value=str(expire_at),
max_age=max_age,
httponly=False, # 前端需要读取
samesite="lax"
)
return LocalLoginResponse(
success=True,
message="登录成功",
user=user.dict()
)
@router.get("/linuxdo/url", response_model=AuthUrlResponse)
async def get_linuxdo_auth_url():
"""获取 LinuxDO 授权 URL"""
state = oauth_service.generate_state()
auth_url = oauth_service.get_authorization_url(state)
# 临时存储 state5分钟有效)
_state_storage[state] = True
return AuthUrlResponse(auth_url=auth_url, state=state)
async def _handle_callback(
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
response: Response = None
):
"""
LinuxDO OAuth2 回调处理
成功后重定向到前端首页,并设置 user_id Cookie
"""
# 检查是否有错误
if error:
raise HTTPException(status_code=400, detail=f"授权失败: {error}")
# 检查必需参数
if not code or not state:
raise HTTPException(status_code=400, detail="缺少 code 或 state 参数")
# 验证 state(防止 CSRF
if state not in _state_storage:
raise HTTPException(status_code=400, detail="无效的 state 参数")
# 删除已使用的 state
del _state_storage[state]
# 1. 使用 code 获取 access_token
token_data = await oauth_service.get_access_token(code)
if not token_data or "access_token" not in token_data:
raise HTTPException(status_code=400, detail="获取访问令牌失败")
access_token = token_data["access_token"]
# 2. 使用 access_token 获取用户信息
user_info = await oauth_service.get_user_info(access_token)
if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败")
# 3. 创建或更新用户
linuxdo_id = str(user_info.get("id"))
username = user_info.get("username", "")
display_name = user_info.get("name", username)
avatar_url = user_info.get("avatar_url")
trust_level = user_info.get("trust_level", 0)
user = await user_manager.create_or_update_from_linuxdo(
linuxdo_id=linuxdo_id,
username=username,
display_name=display_name,
avatar_url=avatar_url,
trust_level=trust_level
)
# 3.5. 初始化用户数据库(如果是新用户)
try:
await init_db(user.user_id)
logger.info(f"用户 {user.user_id} 数据库初始化成功")
except Exception as e:
logger.error(f"用户 {user.user_id} 数据库初始化失败: {e}")
# 继续执行,不影响登录流程(可能是已存在的用户)
# 4. 设置 Cookie 并重定向到前端回调页面
# 使用配置的前端URL,支持不同的部署环境
frontend_url = settings.FRONTEND_URL.rstrip('/')
redirect_url = f"{frontend_url}/auth/callback"
logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}")
redirect_response = RedirectResponse(url=redirect_url)
# 设置 httponly Cookie2小时有效)
max_age = settings.SESSION_EXPIRE_MINUTES * 60
redirect_response.set_cookie(
key="user_id",
value=user.user_id,
max_age=max_age,
httponly=True,
samesite="lax"
)
# 设置过期时间戳 Cookie(用于前端判断)
china_now = get_china_now()
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
expire_at = int(expire_time.timestamp())
logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟")
redirect_response.set_cookie(
key="session_expire_at",
value=str(expire_at),
max_age=max_age,
httponly=False, # 前端需要读取
samesite="lax"
)
return redirect_response
@router.get("/linuxdo/callback")
async def linuxdo_callback(
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
response: Response = None
):
"""LinuxDO OAuth2 回调处理(标准路径)"""
return await _handle_callback(code, state, error, response)
@router.get("/callback")
async def callback_alias(
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
response: Response = None
):
"""LinuxDO OAuth2 回调处理(兼容路径)"""
return await _handle_callback(code, state, error, response)
@router.post("/refresh")
async def refresh_session(request: Request, response: Response):
"""刷新会话 - 延长登录状态"""
# 检查是否已登录
if not hasattr(request.state, "user") or not request.state.user:
raise HTTPException(status_code=401, detail="未登录,无法刷新会话")
user = request.state.user
# 检查当前会话是否即将过期(剩余时间少于阈值)
session_expire_at = request.cookies.get("session_expire_at")
if session_expire_at:
try:
expire_timestamp = int(session_expire_at)
current_timestamp = int(get_china_now().timestamp())
remaining_minutes = (expire_timestamp - current_timestamp) / 60
# 如果剩余时间大于刷新阈值,不需要刷新
if remaining_minutes > settings.SESSION_REFRESH_THRESHOLD_MINUTES:
logger.info(f"⏱️ [刷新会话] 用户 {user.user_id} 会话仍有效,剩余 {int(remaining_minutes)} 分钟")
return {
"message": "会话仍然有效,无需刷新",
"remaining_minutes": int(remaining_minutes),
"expire_at": expire_timestamp
}
except (ValueError, TypeError):
pass # Cookie 格式错误,继续刷新
# 刷新 Cookie
max_age = settings.SESSION_EXPIRE_MINUTES * 60
response.set_cookie(
key="user_id",
value=user.user_id,
max_age=max_age,
httponly=True,
samesite="lax"
)
# 更新过期时间戳
china_now = get_china_now()
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
expire_at = int(expire_time.timestamp())
logger.info(f"[刷新会话] 用户: {user.user_id}")
logger.info(f"[刷新会话] 中国当前时间: {china_now.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)")
logger.info(f"[刷新会话] 中国过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)")
logger.info(f"[刷新会话] 过期时间戳 (秒): {expire_at}")
logger.info(f"[刷新会话] Cookie max_age (秒): {max_age}")
response.set_cookie(
key="session_expire_at",
value=str(expire_at),
max_age=max_age,
httponly=False,
samesite="lax"
)
logger.info(f"用户 {user.user_id} 刷新会话成功")
return {
"message": "会话刷新成功",
"expire_at": expire_at,
"remaining_minutes": settings.SESSION_EXPIRE_MINUTES
}
@router.post("/logout")
async def logout(request: Request, response: Response):
"""退出登录"""
user_id = getattr(request.state, 'user_id', None)
if user_id:
logger.info(f"🚪 [退出] 用户 {user_id} 退出登录")
response.delete_cookie("user_id")
response.delete_cookie("session_expire_at")
return {"message": "退出登录成功"}
@router.get("/user")
async def get_current_user(request: Request):
"""获取当前登录用户信息"""
if not hasattr(request.state, "user") or not request.state.user:
raise HTTPException(status_code=401, detail="未登录")
return request.state.user.dict()