|
|
@@ -1624,12 +1624,17 @@ async def wechat_callback(
|
|
|
|
|
|
|
|
|
@app.get("/api/auth/qq/authorize")
|
|
|
-async def qq_authorize(request: Request, from_page: str = Query(None, alias="from", description="来源页面")):
|
|
|
+async def qq_authorize(
|
|
|
+ request: Request,
|
|
|
+ from_page: str = Query(None, alias="from", description="来源页面"),
|
|
|
+ user_id: str = Query(None, description="可选的现有用户ID,如果提供则更新该用户而不是创建新用户")
|
|
|
+):
|
|
|
"""
|
|
|
获取QQ授权URL
|
|
|
|
|
|
Args:
|
|
|
from_page: 来源页面(如 first-login),用于回调时决定重定向位置
|
|
|
+ user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
|
|
|
|
|
|
Returns:
|
|
|
重定向到QQ授权页面
|
|
|
@@ -1641,22 +1646,75 @@ async def qq_authorize(request: Request, from_page: str = Query(None, alias="fro
|
|
|
import secrets
|
|
|
state_token = secrets.token_urlsafe(32)
|
|
|
|
|
|
- # 如果有来源页面,编码到 state 中(格式:{token}_{from})
|
|
|
- if from_page:
|
|
|
- state = f"{state_token}_{from_page}"
|
|
|
- else:
|
|
|
- state = state_token
|
|
|
+ # state只存储token,from_page和user_id存储在Redis中(避免token包含下划线导致解析错误)
|
|
|
+ state = state_token
|
|
|
|
|
|
# 将 state 存储到 Redis 以便回调校验(存储原始 token 和来源信息)
|
|
|
redis_cache = getattr(request.app.state, 'redis_cache', None)
|
|
|
+ state_stored = False
|
|
|
+ state_key = f"oauth:qq:state:{state_token}"
|
|
|
+
|
|
|
+ logger.info(f"📝 [QQ授权] 开始存储state - state_token={state_token}, from_page={from_page}, user_id={user_id}, state={state}, state_key={state_key}")
|
|
|
+
|
|
|
if redis_cache:
|
|
|
try:
|
|
|
redis_client = await redis_cache._get_redis()
|
|
|
- # 存储原始 token 和来源信息
|
|
|
- state_data = {"token": state_token, "from": from_page} if from_page else {"token": state_token}
|
|
|
- await redis_client.setex(f"oauth:qq:state:{state_token}", 600, json.dumps(state_data))
|
|
|
+
|
|
|
+ # 🔥 检查Redis连接状态
|
|
|
+ try:
|
|
|
+ ping_result = await redis_client.ping()
|
|
|
+ logger.debug(f"✅ [QQ授权] Redis连接正常: ping={ping_result}")
|
|
|
+ except Exception as ping_err:
|
|
|
+ logger.error(f"❌ [QQ授权] Redis ping失败: {ping_err}")
|
|
|
+
|
|
|
+ # 🔥 检查Redis配置信息(DB、连接池等)
|
|
|
+ try:
|
|
|
+ redis_info = await redis_client.info('server')
|
|
|
+ redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
|
|
|
+ logger.debug(f"🔍 [QQ授权] Redis配置 - DB={redis_db}, server_version={redis_info.get('redis_version', 'unknown')}")
|
|
|
+ except Exception as info_err:
|
|
|
+ logger.debug(f"⚠️ [QQ授权] 获取Redis信息失败: {info_err}")
|
|
|
+
|
|
|
+ # 🔥 检查key是否已存在(防止重复)
|
|
|
+ existing_value = await redis_client.get(state_key)
|
|
|
+ if existing_value:
|
|
|
+ logger.warning(f"⚠️ [QQ授权] state key已存在,将被覆盖: key={state_key}, old_value={existing_value[:50]}...")
|
|
|
+
|
|
|
+ # 存储原始 token、来源信息和 user_id
|
|
|
+ state_data = {"token": state_token}
|
|
|
+ if from_page:
|
|
|
+ state_data["from"] = from_page
|
|
|
+ if user_id:
|
|
|
+ state_data["user_id"] = user_id
|
|
|
+ state_value = json.dumps(state_data)
|
|
|
+ logger.debug(f"📦 [QQ授权] 准备存储 - key={state_key}, value={state_value}, TTL=1800s")
|
|
|
+
|
|
|
+ # 🔥 增加TTL到1800秒(30分钟),避免授权流程过长导致过期
|
|
|
+ await redis_client.setex(state_key, 1800, state_value)
|
|
|
+ state_stored = True
|
|
|
+
|
|
|
+ # 🔥 验证存储是否成功
|
|
|
+ verify_value = await redis_client.get(state_key)
|
|
|
+ if verify_value:
|
|
|
+ # 🔥 检查TTL
|
|
|
+ ttl = await redis_client.ttl(state_key)
|
|
|
+ logger.info(f"✅ [QQ授权] QQ state 已存储并验证成功 - key={state_key}, token={state_token[:16]}..., TTL={ttl}s, value_length={len(verify_value)}")
|
|
|
+ else:
|
|
|
+ logger.error(f"❌ [QQ授权] QQ state 存储后验证失败 - key={state_key}, token={state_token[:16]}...")
|
|
|
+
|
|
|
+ # 🔥 尝试查找所有相关的keys
|
|
|
+ try:
|
|
|
+ pattern = f"oauth:qq:state:*"
|
|
|
+ keys = []
|
|
|
+ async for key in redis_client.scan_iter(match=pattern, count=100):
|
|
|
+ keys.append(key)
|
|
|
+ logger.debug(f"🔍 [QQ授权] Redis中存在的QQ state keys: {keys[:10]}... (共{len(keys)}个)")
|
|
|
+ except Exception as scan_err:
|
|
|
+ logger.debug(f"🔍 [QQ授权] 扫描keys失败: {scan_err}")
|
|
|
except Exception as e:
|
|
|
- logger.warning(f"存储 QQ state 失败,将跳过校验: {e}")
|
|
|
+ logger.error(f"❌ [QQ授权] 存储 QQ state 失败 - key={state_key}, error={e}", exc_info=True)
|
|
|
+ else:
|
|
|
+ logger.warning("⚠️ [QQ授权] Redis缓存服务不可用,QQ state 验证将被跳过")
|
|
|
|
|
|
# 确保回调地址格式正确(去除末尾斜杠,确保与QQ应用配置完全一致)
|
|
|
redirect_uri = settings.QQ_REDIRECT_URI.rstrip('/')
|
|
|
@@ -1697,39 +1755,107 @@ async def qq_callback(
|
|
|
重定向到前端页面(带token)
|
|
|
"""
|
|
|
try:
|
|
|
+ logger.info(f"📥 [QQ回调] 收到回调请求 - code={'***' if code else '(empty)'}, state={state}")
|
|
|
+
|
|
|
# 0. 验证 state 防止 CSRF,并提取来源信息
|
|
|
from_page = None
|
|
|
- state_token = state
|
|
|
+ user_id_from_state = None
|
|
|
+ state_token = state # state就是token,不再需要分割
|
|
|
if state:
|
|
|
- # 解析 state(格式可能是 {token} 或 {token}_{from})
|
|
|
- if "_" in state:
|
|
|
- parts = state.split("_", 1)
|
|
|
- state_token = parts[0]
|
|
|
- from_page = parts[1] if len(parts) > 1 else None
|
|
|
- else:
|
|
|
- state_token = state
|
|
|
+ logger.debug(f"🔍 [QQ回调] 开始解析state - state={state}, length={len(state)}")
|
|
|
|
|
|
redis_cache = getattr(request.app.state, 'redis_cache', None)
|
|
|
+ state_key = f"oauth:qq:state:{state_token}"
|
|
|
+ logger.info(f"🔑 [QQ回调] 准备验证state - state_token={state_token[:16]}..., state_key={state_key}")
|
|
|
+
|
|
|
if redis_cache:
|
|
|
try:
|
|
|
redis_client = await redis_cache._get_redis()
|
|
|
- state_key = f"oauth:qq:state:{state_token}"
|
|
|
- state_data_str = await redis_client.get(state_key)
|
|
|
- if not state_data_str:
|
|
|
- logger.warning(f"QQ state 验证失败: {state_token}")
|
|
|
- return RedirectResponse(
|
|
|
- url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
|
|
|
- )
|
|
|
- # 如果存储的是 JSON 格式,解析获取来源信息
|
|
|
+
|
|
|
+ # 🔥 检查Redis连接状态
|
|
|
try:
|
|
|
- state_data = json.loads(state_data_str)
|
|
|
- from_page = state_data.get("from") or from_page
|
|
|
- except (json.JSONDecodeError, TypeError):
|
|
|
- # 如果不是 JSON 格式,使用从 state 中解析的 from_page
|
|
|
- pass
|
|
|
- await redis_client.delete(state_key)
|
|
|
+ ping_result = await redis_client.ping()
|
|
|
+ logger.debug(f"✅ [QQ回调] Redis连接正常: ping={ping_result}")
|
|
|
+ except Exception as ping_error:
|
|
|
+ logger.error(f"❌ [QQ回调] Redis ping失败: {ping_error}", exc_info=True)
|
|
|
+ # Redis连接失败时,跳过验证但记录警告(为了不影响用户体验)
|
|
|
+ logger.warning("⚠️ [QQ回调] Redis连接失败,跳过state验证(可能存在安全风险)")
|
|
|
+ # 继续处理,不阻止登录流程
|
|
|
+ else:
|
|
|
+ # 🔥 检查Redis配置信息(DB、连接池等)
|
|
|
+ try:
|
|
|
+ redis_db = redis_client.connection_pool.connection_kwargs.get('db', 0)
|
|
|
+ logger.debug(f"🔍 [QQ回调] Redis配置 - DB={redis_db}")
|
|
|
+ except Exception as info_err:
|
|
|
+ logger.debug(f"⚠️ [QQ回调] 获取Redis配置失败: {info_err}")
|
|
|
+
|
|
|
+ # 🔥 尝试查找key
|
|
|
+ logger.debug(f"🔍 [QQ回调] 开始查找key: {state_key}")
|
|
|
+ state_data_str = await redis_client.get(state_key)
|
|
|
+
|
|
|
+ if not state_data_str:
|
|
|
+ # 🔥 增加详细日志,帮助诊断问题
|
|
|
+ logger.warning(f"❌ [QQ回调] QQ state 验证失败 - state_token={state_token[:16]}..., state_key={state_key}")
|
|
|
+
|
|
|
+ # 🔥 检查key的TTL(如果key存在但值为None,可能是其他问题)
|
|
|
+ try:
|
|
|
+ exists = await redis_client.exists(state_key)
|
|
|
+ logger.debug(f"🔍 [QQ回调] key存在性检查: exists={exists}")
|
|
|
+ if exists:
|
|
|
+ ttl = await redis_client.ttl(state_key)
|
|
|
+ logger.debug(f"🔍 [QQ回调] key存在但值为空,TTL={ttl}s")
|
|
|
+ except Exception as exists_err:
|
|
|
+ logger.debug(f"🔍 [QQ回调] 检查key存在性失败: {exists_err}")
|
|
|
+
|
|
|
+ # 🔥 尝试查找所有相关的state key(用于调试)
|
|
|
+ try:
|
|
|
+ pattern = f"oauth:qq:state:*"
|
|
|
+ keys = []
|
|
|
+ async for key in redis_client.scan_iter(match=pattern, count=100):
|
|
|
+ keys.append(key)
|
|
|
+ if keys:
|
|
|
+ logger.warning(f"🔍 [QQ回调] Redis中存在其他QQ state keys (共{len(keys)}个): {keys[:10]}")
|
|
|
+ # 🔥 检查这些keys的TTL
|
|
|
+ for key in keys[:5]:
|
|
|
+ try:
|
|
|
+ ttl = await redis_client.ttl(key)
|
|
|
+ value = await redis_client.get(key)
|
|
|
+ logger.debug(f"🔍 [QQ回调] key详情 - {key}: TTL={ttl}s, value_length={len(value) if value else 0}")
|
|
|
+ except Exception as key_err:
|
|
|
+ logger.debug(f"🔍 [QQ回调] 获取key详情失败 {key}: {key_err}")
|
|
|
+ else:
|
|
|
+ logger.warning(f"🔍 [QQ回调] Redis中不存在任何QQ state keys")
|
|
|
+ except Exception as scan_error:
|
|
|
+ logger.debug(f"🔍 [QQ回调] 扫描Redis keys失败: {scan_error}")
|
|
|
+
|
|
|
+ return RedirectResponse(
|
|
|
+ url=f"{settings.FRONTEND_URL}/register?error=qq_auth_failed&reason=invalid_state"
|
|
|
+ )
|
|
|
+
|
|
|
+ # 🔥 找到key,记录详细信息
|
|
|
+ ttl = await redis_client.ttl(state_key)
|
|
|
+ logger.info(f"✅ [QQ回调] 找到state key - key={state_key}, TTL={ttl}s, value_length={len(state_data_str)}")
|
|
|
+
|
|
|
+ # 如果存储的是 JSON 格式,解析获取来源信息和user_id
|
|
|
+ try:
|
|
|
+ state_data = json.loads(state_data_str)
|
|
|
+ from_page = state_data.get("from") or from_page
|
|
|
+ user_id_from_state = state_data.get("user_id")
|
|
|
+ logger.info(f"✅ [QQ回调] QQ state 验证成功 - state_token={state_token[:16]}..., 来源: {from_page}, user_id: {user_id_from_state}, data={state_data}")
|
|
|
+ except (json.JSONDecodeError, TypeError) as json_err:
|
|
|
+ # 如果不是 JSON 格式,使用从 state 中解析的 from_page
|
|
|
+ logger.debug(f"✅ [QQ回调] QQ state 验证成功 (非JSON格式) - state_token={state_token[:16]}..., value={state_data_str[:50]}..., json_err={json_err}")
|
|
|
+ pass
|
|
|
+
|
|
|
+ # 🔥 删除已使用的state key
|
|
|
+ deleted = await redis_client.delete(state_key)
|
|
|
+ logger.debug(f"🗑️ [QQ回调] 删除state key - key={state_key}, deleted={deleted}")
|
|
|
+
|
|
|
except Exception as state_error:
|
|
|
- logger.warning(f"QQ state 验证异常: {state_error},继续流程")
|
|
|
+ logger.error(f"❌ [QQ回调] QQ state 验证异常 - state_token={state_token[:16]}..., error={state_error}", exc_info=True)
|
|
|
+ # 🔥 验证异常时不阻止流程,但记录详细错误
|
|
|
+ else:
|
|
|
+ logger.warning("⚠️ [QQ回调] Redis缓存服务不可用,跳过state验证")
|
|
|
|
|
|
if not settings.QQ_APP_ID or not settings.QQ_APP_KEY:
|
|
|
raise HTTPException(status_code=500, detail="QQ OAuth未配置")
|
|
|
@@ -1847,50 +1973,63 @@ async def qq_callback(
|
|
|
if from_page == "login":
|
|
|
# 从 login 页面来的,需要先检查用户是否存在
|
|
|
# 如果用户不存在,不允许自动注册,应该提示用户先注册
|
|
|
+ logger.info(f"🔍 [QQ回调] 开始检查用户是否存在 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
|
|
|
user_exists = await user_service.check_user_exists_by_qq_openid(openid=openid, unionid=unionid)
|
|
|
+ logger.info(f"🔍 [QQ回调] 用户存在性检查结果 - user_exists={user_exists}, openid={openid[:16]}...")
|
|
|
|
|
|
if not user_exists:
|
|
|
- # 用户不存在,重定向到 welcome 页面并提示
|
|
|
- logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid}")
|
|
|
+ # 用户不存在,重定向到 begin 页面并提示(和手机号登录保持一致)
|
|
|
+ logger.info(f"⚠️ QQ用户未注册(来自login页面): openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}...")
|
|
|
return RedirectResponse(
|
|
|
- url=f"{settings.FRONTEND_URL}/welcome?error=user_not_registered&oauth=qq"
|
|
|
+ url=f"{settings.FRONTEND_URL}/begin?error=user_not_registered&oauth=qq"
|
|
|
)
|
|
|
|
|
|
# 用户存在,使用openid登录
|
|
|
+ # 不传递QQ返回的nickname,避免覆盖用户原有用户名
|
|
|
+ logger.info(f"✅ [QQ回调] 用户存在,开始登录 - openid={openid[:16]}...")
|
|
|
user = await user_service.login_with_qq(
|
|
|
openid=openid,
|
|
|
- username=nickname,
|
|
|
+ username="", # 不使用QQ返回的nickname
|
|
|
unionid=unionid,
|
|
|
)
|
|
|
|
|
|
- # 重定向到聊天界面
|
|
|
- frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
|
|
|
+ # 🔧 生成会话 token(前端需要 token 来调用 getUserInfo API)
|
|
|
+ session_data = await _generate_session_token(user['user_id'])
|
|
|
+ token = session_data['token']
|
|
|
+ logger.info(f"✅ [QQ回调] 已生成会话 token - user_id={user['user_id']}, token={token[:16]}...")
|
|
|
+
|
|
|
+ # 重定向到聊天界面(传递 token,避免前端调用 ensureSession 时使用错误的 token)
|
|
|
+ logger.info(f"✅ [QQ回调] 登录成功,准备重定向到聊天界面 - user_id={user['user_id']}")
|
|
|
+ frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&token={token}"
|
|
|
return RedirectResponse(url=frontend_url)
|
|
|
|
|
|
elif from_page == "first-login":
|
|
|
# 从 first-login 页面来的,允许自动注册
|
|
|
- # 使用openid登录/注册(login_with_qq 会自动创建新用户)
|
|
|
+ # 如果提供了 user_id,则更新现有用户;否则创建新用户
|
|
|
+ # 不传递QQ返回的nickname,避免覆盖用户原有用户名
|
|
|
user = await user_service.login_with_qq(
|
|
|
openid=openid,
|
|
|
- username=nickname,
|
|
|
+ username="", # 不使用QQ返回的nickname
|
|
|
unionid=unionid,
|
|
|
+ user_id=user_id_from_state,
|
|
|
)
|
|
|
|
|
|
- # 重定向到 first-login-reward
|
|
|
- frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
|
|
|
+ # 重定向到 first-login-reward(不传递username,避免使用QQ返回的nickname)
|
|
|
+ frontend_url = f"{settings.FRONTEND_URL}/first-login-reward?oauth=qq&user_id={user['user_id']}"
|
|
|
return RedirectResponse(url=frontend_url)
|
|
|
|
|
|
else:
|
|
|
# 其他来源(如 register 页面),允许自动注册
|
|
|
# 使用openid登录/注册(login_with_qq 会自动创建新用户)
|
|
|
+ # 不传递QQ返回的nickname,避免使用QQ昵称作为用户名
|
|
|
user = await user_service.login_with_qq(
|
|
|
openid=openid,
|
|
|
- username=nickname,
|
|
|
+ username="", # 不使用QQ返回的nickname
|
|
|
unionid=unionid,
|
|
|
)
|
|
|
|
|
|
- # 默认重定向到聊天界面
|
|
|
- frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}&username={urllib.parse.quote(user['username'])}"
|
|
|
+ # 默认重定向到聊天界面(不传递username,由前端从数据库同步)
|
|
|
+ frontend_url = f"{settings.FRONTEND_URL}/chatbot?oauth=qq&user_id={user['user_id']}"
|
|
|
return RedirectResponse(url=frontend_url)
|
|
|
|
|
|
except Exception as e:
|