Files
ai-agent-admin/backend-fastapi/core/oauth/api.py
T
2026-06-08 18:14:59 +08:00

243 lines
8.9 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
OAuth API - OAuth 接口层
提供第三方 OAuth 登录的 API 接口
"""
import json
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from utils.redis import RedisClient
from core.oauth.schema import OAuthCallbackIn, OAuthLoginOut, AuthorizeUrlOut
from core.oauth.service import OAUTH_PROVIDERS
# CSRF state 相关常量
OAUTH_STATE_PREFIX = "oauth_state:"
OAUTH_STATE_EXPIRE_SECONDS = 300 # 5分钟过期
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/oauth", tags=['OAuth登录'])
# 注意:路由路径不要重复添加 /oauth 前缀
def get_client_ip(request: Request) -> str:
"""获取客户端 IP 地址"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _get_request_origin(request: Request) -> str:
"""从 Origin / Referer 中提取请求来源 origin"""
from urllib.parse import urlparse
origin = request.headers.get("Origin") or ""
if not origin:
referer = request.headers.get("Referer") or ""
if referer:
parsed = urlparse(referer)
origin = f"{parsed.scheme}://{parsed.netloc}"
return origin
async def _resolve_redirect_uri(request: Request, provider: str, redirect_uri: str = '') -> str:
"""
根据请求来源自动选择 OAuth 回调地址。
优先级: 显式传入 > H5 回调地址(来源域名匹配时) > 空(由服务层使用 Web 默认回调)
"""
if redirect_uri:
return redirect_uri
from urllib.parse import urlparse
from app.config_manager import config_manager
oauth_config = await config_manager.get_group(f"oauth_{provider}")
h5_uri = (oauth_config.get("h5_redirect_uri") or "").strip()
if not h5_uri:
return ''
origin = _get_request_origin(request)
if not origin:
return ''
# 如果来源域名与 H5 回调地址的域名一致,使用 H5 回调地址
origin_host = urlparse(origin).netloc
h5_host = urlparse(h5_uri).netloc
if origin_host == h5_host:
return h5_uri
return ''
def _parse_oauth_state_payload(raw_value: str) -> dict:
"""解析 authorize 阶段写入 Redis 的 state 载荷(兼容旧版纯字符串)"""
if not raw_value:
return {}
try:
payload = json.loads(raw_value)
return payload if isinstance(payload, dict) else {}
except json.JSONDecodeError:
return {}
@router.get("/{provider}/authorize", response_model=AuthorizeUrlOut, summary="获取 OAuth 授权 URL")
async def get_oauth_authorize_url(
request: Request,
provider: str,
state: str = Query(default='', description="前端传递的额外状态参数(如 redirect 信息)"),
redirect_uri: str = Query(default='', description="自定义回调地址(不同前端可传入各自的回调 URL)"),
):
"""
获取 OAuth 授权 URL (通用接口)
Args:
provider: OAuth 提供商 (gitee/github/qq/google/wechat/microsoft/dingtalk/feishu)
state: 前端传递的额外状态参数(如 redirect 信息),会被嵌入到 CSRF state 中
redirect_uri: 自定义回调地址,不传则根据请求来源自动推断
前端应该将用户重定向到此 URL
"""
# 验证 provider
if provider not in OAUTH_PROVIDERS:
raise HTTPException(status_code=400, detail=f"不支持的 OAuth 提供商: {provider}")
try:
# 生成 CSRF token 并存入 Redis
csrf_token = secrets.token_urlsafe(32)
redis = await RedisClient.get_client()
# 将 csrf_token 和前端传递的 state 合并为一个 JSON 字符串
state_data = {"csrf": csrf_token}
if state:
state_data["payload"] = state
combined_state = json.dumps(state_data, separators=(',', ':'))
# 自动推断回调地址(Web 默认 / H5 按来源域名匹配)
resolved_redirect_uri = await _resolve_redirect_uri(request, provider, redirect_uri)
service_class = OAUTH_PROVIDERS[provider]
client_config = await service_class.get_client_config_async()
final_redirect_uri = resolved_redirect_uri or client_config.get('redirect_uri', '')
# 将 CSRF token 与实际使用的 redirect_uri 一并存入 Redis,供 callback 换 token 时使用
await redis.set(
f"{OAUTH_STATE_PREFIX}{csrf_token}",
json.dumps({"redirect_uri": final_redirect_uri}, separators=(',', ':')),
ex=OAUTH_STATE_EXPIRE_SECONDS
)
authorize_url = await service_class.get_authorize_url(
combined_state,
redirect_uri=resolved_redirect_uri or None,
)
return AuthorizeUrlOut(authorize_url=authorize_url)
except Exception as e:
logger.error(f"获取 {provider} 授权 URL 失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取授权 URL 失败: {str(e)}")
@router.post("/{provider}/callback", response_model=OAuthLoginOut, summary="OAuth 回调处理")
async def oauth_callback(
request: Request,
provider: str,
data: OAuthCallbackIn,
db: AsyncSession = Depends(get_db),
):
"""
处理 OAuth 回调 (通用接口)
Args:
provider: OAuth 提供商 (gitee/github/qq/google/wechat/microsoft/dingtalk/feishu)
data: 回调数据(包含 code 和 state)
前端在授权后会获得 code,将 code 发送到此接口完成登录
"""
# 验证 provider
if provider not in OAUTH_PROVIDERS:
raise HTTPException(status_code=400, detail=f"不支持的 OAuth 提供商: {provider}")
# 验证 CSRF state,并读取 authorize 阶段保存的 redirect_uri
oauth_redirect_uri = ''
if data.state:
try:
state_data = json.loads(data.state)
csrf_token = state_data.get("csrf")
if csrf_token:
redis = await RedisClient.get_client()
redis_key = f"{OAUTH_STATE_PREFIX}{csrf_token}"
exists = await redis.get(redis_key)
if not exists:
logger.warning(f"{provider} OAuth 回调 CSRF 验证失败: state 已过期或无效")
raise HTTPException(status_code=400, detail="授权请求已过期,请重新登录")
oauth_redirect_uri = _parse_oauth_state_payload(exists).get("redirect_uri", "") or ''
# 验证通过后删除(一次性使用)
await redis.delete(redis_key)
else:
logger.warning(f"{provider} OAuth 回调缺少 CSRF token")
raise HTTPException(status_code=400, detail="无效的授权状态参数")
except json.JSONDecodeError:
logger.warning(f"{provider} OAuth 回调 state 解析失败: {data.state}")
raise HTTPException(status_code=400, detail="无效的授权状态参数")
else:
logger.warning(f"{provider} OAuth 回调缺少 state 参数")
raise HTTPException(status_code=400, detail="缺少授权状态参数")
try:
service_class = OAUTH_PROVIDERS[provider]
# 获取客户端 IP 和 User-Agent
ip_address = get_client_ip(request)
user_agent = request.headers.get('User-Agent', '')
# 获取设备标识
from utils.client_info import get_device_id
device_id = get_device_id(request)
# 处理 OAuth 登录(传递 provider 作为登录方式和设备标识)
user, access_token, refresh_token, expire_time = await service_class.handle_oauth_login(
db=db,
code=data.code,
ip_address=ip_address,
user_agent=user_agent,
login_type=provider,
device_id=device_id,
redirect_uri=oauth_redirect_uri or None,
)
# 构造返回数据
user_info = {
'id': str(user.id),
'username': user.username,
'name': user.name,
'email': user.email,
'avatar': user.avatar,
'user_type': user.user_type,
'is_superuser': user.is_superuser,
}
logger.info(f"{provider.capitalize()} OAuth 登录成功: {user.username}")
return OAuthLoginOut(
access_token=access_token,
refresh_token=refresh_token,
expire=expire_time,
user_info=user_info,
)
except ValueError as e:
logger.warning(f"{provider.capitalize()} OAuth 登录失败: {str(e)}")
raise HTTPException(status_code=401, detail=str(e))
except Exception as e:
logger.error(f"{provider.capitalize()} OAuth 登录异常: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="登录失败,请稍后重试")