Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OAuth 第三方登录模块
|
||||
"""
|
||||
from core.oauth.api import router
|
||||
|
||||
__all__ = ['router']
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/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="登录失败,请稍后重试")
|
||||
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OAuth 基础服务类
|
||||
提供通用的 OAuth 认证流程(异步版本)
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from utils.redis import RedisClient
|
||||
from core.user.model import User
|
||||
from core.login_log.service import LoginLogService
|
||||
from utils.security import create_access_token, create_refresh_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis中存储refresh token的key前缀
|
||||
REFRESH_TOKEN_PREFIX = "refresh_token:"
|
||||
|
||||
|
||||
class BaseOAuthService(ABC):
|
||||
"""OAuth 服务基类"""
|
||||
|
||||
# 子类需要定义这些属性
|
||||
PROVIDER_NAME: str = None # 提供商名称,如 'gitee', 'github'
|
||||
AUTHORIZE_URL: str = None # 授权 URL
|
||||
TOKEN_URL: str = None # 获取 token 的 URL
|
||||
USER_INFO_URL: str = None # 获取用户信息的 URL
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_client_config(cls) -> Dict[str, str]:
|
||||
"""
|
||||
获取客户端配置(同步,从 settings 读取)
|
||||
|
||||
Returns:
|
||||
Dict: 包含 client_id, client_secret, redirect_uri
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def get_client_config_async(cls) -> Dict[str, str]:
|
||||
"""
|
||||
异步获取客户端配置(从 config_manager 三级配置读取)
|
||||
子类可重写此方法以支持从数据库/Redis读取配置。
|
||||
默认回退到同步 get_client_config()。
|
||||
"""
|
||||
return cls.get_client_config()
|
||||
|
||||
@classmethod
|
||||
async def get_authorize_url(cls, state: str = None, redirect_uri: str = None) -> str:
|
||||
"""
|
||||
获取 OAuth 授权 URL
|
||||
|
||||
Args:
|
||||
state: 状态参数,用于防止 CSRF 攻击
|
||||
redirect_uri: 自定义回调地址,不传则使用配置的默认值
|
||||
|
||||
Returns:
|
||||
str: 授权 URL
|
||||
"""
|
||||
config = await cls.get_client_config_async()
|
||||
params = {
|
||||
'client_id': config['client_id'],
|
||||
'redirect_uri': redirect_uri or config['redirect_uri'],
|
||||
'response_type': 'code',
|
||||
}
|
||||
if state:
|
||||
params['state'] = state
|
||||
|
||||
params.update(cls.get_extra_authorize_params())
|
||||
|
||||
query_string = urlencode(params, quote_via=quote)
|
||||
return f"{cls.AUTHORIZE_URL}?{query_string}"
|
||||
|
||||
@classmethod
|
||||
def get_extra_authorize_params(cls) -> Dict[str, str]:
|
||||
"""
|
||||
获取额外的授权参数(子类可覆盖)
|
||||
|
||||
Returns:
|
||||
Dict: 额外参数
|
||||
"""
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
async def get_access_token(cls, code: str, redirect_uri: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
使用授权码获取访问令牌
|
||||
|
||||
Args:
|
||||
code: 授权码
|
||||
redirect_uri: 授权阶段实际使用的回调地址(须与 authorize 一致)
|
||||
|
||||
Returns:
|
||||
Optional[str]: 访问令牌,失败返回 None
|
||||
"""
|
||||
try:
|
||||
config = await cls.get_client_config_async()
|
||||
data = {
|
||||
'grant_type': 'authorization_code',
|
||||
'code': code,
|
||||
'client_id': config['client_id'],
|
||||
'client_secret': config['client_secret'],
|
||||
'redirect_uri': redirect_uri or config['redirect_uri'],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
cls.TOKEN_URL,
|
||||
data=data,
|
||||
headers=cls.get_token_request_headers(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
access_token = result.get('access_token')
|
||||
|
||||
if not access_token:
|
||||
logger.error(f"获取 {cls.PROVIDER_NAME} access_token 失败: {result}")
|
||||
return None
|
||||
|
||||
return access_token
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"请求 {cls.PROVIDER_NAME} access_token 失败: {str(e)}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取 {cls.PROVIDER_NAME} access_token 异常: {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_token_request_headers(cls) -> Dict[str, str]:
|
||||
"""
|
||||
获取 token 请求的 headers(子类可覆盖)
|
||||
|
||||
Returns:
|
||||
Dict: headers
|
||||
"""
|
||||
return {}
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def get_user_info(cls, access_token: str) -> Optional[Dict]:
|
||||
"""
|
||||
使用访问令牌获取用户信息(子类必须实现)
|
||||
|
||||
Args:
|
||||
access_token: 访问令牌
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 用户信息字典,失败返回 None
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def normalize_user_info(cls, raw_user_info: Dict) -> Dict:
|
||||
"""
|
||||
标准化用户信息(子类必须实现)
|
||||
|
||||
将不同 OAuth 提供商的用户信息格式统一为标准格式
|
||||
|
||||
Args:
|
||||
raw_user_info: 原始用户信息
|
||||
|
||||
Returns:
|
||||
Dict: 标准化后的用户信息,包含:
|
||||
- provider_id: 提供商的用户 ID
|
||||
- username: 用户名
|
||||
- name: 显示名称
|
||||
- email: 邮箱
|
||||
- avatar: 头像 URL
|
||||
- bio: 个人简介
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_user_id_field(cls) -> str:
|
||||
"""
|
||||
获取用户 ID 字段名(如 gitee_id, github_id)
|
||||
|
||||
Returns:
|
||||
str: 字段名
|
||||
"""
|
||||
return f"{cls.PROVIDER_NAME}_id"
|
||||
|
||||
@classmethod
|
||||
async def handle_oauth_login(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
code: str,
|
||||
ip_address: str,
|
||||
user_agent: str = None,
|
||||
login_type: str = None,
|
||||
device_id: str = None,
|
||||
redirect_uri: str = None,
|
||||
) -> Tuple[User, str, str, int]:
|
||||
"""
|
||||
处理 OAuth 登录流程
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
code: 授权码
|
||||
ip_address: 用户 IP 地址
|
||||
user_agent: 用户代理字符串
|
||||
login_type: 登录方式 (gitee/github/qq/google/wechat/microsoft)
|
||||
device_id: 设备标识(用于多设备登录)
|
||||
|
||||
Returns:
|
||||
Tuple: (user, access_token, refresh_token, expire_time)
|
||||
|
||||
Raises:
|
||||
ValueError: 登录失败时抛出
|
||||
"""
|
||||
# 1. 使用 code 换取 access_token
|
||||
access_token = await cls.get_access_token(code, redirect_uri=redirect_uri)
|
||||
if not access_token:
|
||||
raise ValueError(f"获取 {cls.PROVIDER_NAME} 访问令牌失败")
|
||||
|
||||
# 2. 使用 access_token 获取用户信息
|
||||
raw_user_info = await cls.get_user_info(access_token)
|
||||
if not raw_user_info:
|
||||
raise ValueError(f"获取 {cls.PROVIDER_NAME} 用户信息失败")
|
||||
|
||||
# 3. 标准化用户信息
|
||||
user_info = cls.normalize_user_info(raw_user_info)
|
||||
provider_id = user_info['provider_id']
|
||||
username = user_info['username']
|
||||
name = user_info['name']
|
||||
email = user_info.get('email')
|
||||
avatar = user_info.get('avatar')
|
||||
bio = user_info.get('bio')
|
||||
|
||||
# 4. 查找或创建用户
|
||||
user_id_field = cls.get_user_id_field()
|
||||
|
||||
# 根据 provider_id 查找用户
|
||||
stmt = select(User).where(
|
||||
getattr(User, user_id_field) == provider_id,
|
||||
User.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
is_superadmin = getattr(settings, 'GRANT_ADMIN_TO_OAUTH_USER', False)
|
||||
default_dept_id = getattr(settings, 'OAUTH_DEFAULT_DEPT_ID', None)
|
||||
|
||||
if user:
|
||||
# 用户已存在,更新信息
|
||||
logger.info(f"{cls.PROVIDER_NAME} 用户已存在: {username} (ID: {provider_id})")
|
||||
|
||||
if email and not user.email:
|
||||
user.email = email
|
||||
if bio and not user.bio:
|
||||
user.bio = bio
|
||||
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
else:
|
||||
# 用户不存在,创建新用户
|
||||
logger.info(f"创建新的 {cls.PROVIDER_NAME} 用户: {username} (ID: {provider_id})")
|
||||
|
||||
# 生成唯一的用户名
|
||||
unique_username = username
|
||||
counter = 1
|
||||
while True:
|
||||
stmt = select(User).where(User.username == unique_username)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none() is None:
|
||||
break
|
||||
unique_username = f"{username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 创建用户
|
||||
create_kwargs = {
|
||||
'username': unique_username,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'bio': bio,
|
||||
user_id_field: provider_id,
|
||||
'oauth_provider': cls.PROVIDER_NAME,
|
||||
'user_type': 1, # 普通用户
|
||||
'user_status': 1, # 正常状态
|
||||
'is_active': True,
|
||||
'is_superuser': is_superadmin,
|
||||
'dept_id': default_dept_id,
|
||||
}
|
||||
user = User(**create_kwargs)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
logger.info(f"{cls.PROVIDER_NAME} 用户创建成功: {unique_username}")
|
||||
|
||||
# 更新用户最后登录方式
|
||||
if login_type:
|
||||
user.last_login_type = login_type
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# 先提交用户数据,确保user.id可用
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# 检查用户状态
|
||||
if not user.is_active:
|
||||
raise ValueError("账户已被禁用")
|
||||
|
||||
if user.user_status == 0:
|
||||
raise ValueError("账户已被禁用")
|
||||
|
||||
if user.user_status == 2:
|
||||
raise ValueError("账户已被锁定,请联系管理员")
|
||||
|
||||
# 5. 生成 JWT token(token中只存身份标识,不存角色等动态信息)
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token_expires = timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
token_data = {
|
||||
"sub": user.id,
|
||||
"username": user.username,
|
||||
}
|
||||
jwt_access_token = create_access_token(token_data, access_token_expires, device_id=device_id)
|
||||
jwt_refresh_token = create_refresh_token(token_data, refresh_token_expires, device_id=device_id)
|
||||
|
||||
# 将用户动态信息缓存到 Redis
|
||||
from utils.user_info_cache import set_cached_user_info
|
||||
from core.user.service import UserService
|
||||
role_ids = await UserService.get_user_role_ids(db, user.id)
|
||||
await set_cached_user_info(user.id, role_ids, user.dept_id, user.is_superuser)
|
||||
expire_time = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
# 将refresh token存入Redis
|
||||
redis = await RedisClient.get_client()
|
||||
|
||||
# 如果不允许多设备登录,删除该用户的所有旧设备token
|
||||
if not settings.ALLOW_MULTI_DEVICE_LOGIN:
|
||||
# 查找并删除该用户的所有refresh token和access token
|
||||
refresh_pattern = f"{REFRESH_TOKEN_PREFIX}{user.id}:*"
|
||||
access_pattern = f"access_token:{user.id}:*"
|
||||
|
||||
# 删除所有refresh token
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await redis.scan(cursor, match=refresh_pattern, count=100)
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# 删除所有access token
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await redis.scan(cursor, match=access_pattern, count=100)
|
||||
if keys:
|
||||
await redis.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
# 存储新的refresh token
|
||||
redis_key = f"{REFRESH_TOKEN_PREFIX}{user.id}:{device_id}" if device_id else f"{REFRESH_TOKEN_PREFIX}{user.id}"
|
||||
await redis.set(
|
||||
redis_key,
|
||||
jwt_refresh_token,
|
||||
ex=int(refresh_token_expires.total_seconds())
|
||||
)
|
||||
|
||||
# 存储 access token(用于判断设备在线状态)
|
||||
if device_id:
|
||||
await redis.set(
|
||||
f"access_token:{user.id}:{device_id}",
|
||||
jwt_access_token,
|
||||
ex=int(access_token_expires.total_seconds())
|
||||
)
|
||||
|
||||
# 6. 记录登录日志(record_login内部会commit)
|
||||
await LoginLogService.record_login(
|
||||
db=db,
|
||||
username=user.username,
|
||||
user_id=str(user.id),
|
||||
status=1,
|
||||
login_ip=ip_address,
|
||||
user_agent=user_agent,
|
||||
login_type=login_type or cls.PROVIDER_NAME,
|
||||
)
|
||||
|
||||
return user, jwt_access_token, jwt_refresh_token, expire_time
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OAuth Schema - OAuth 数据模型
|
||||
定义 OAuth 相关的请求和响应数据结构
|
||||
"""
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OAuthCallbackIn(BaseModel):
|
||||
"""OAuth 回调请求参数 (通用)"""
|
||||
code: str = Field(..., description="授权码")
|
||||
state: Optional[str] = Field(default=None, description="状态参数")
|
||||
|
||||
|
||||
class AuthorizeUrlOut(BaseModel):
|
||||
"""授权 URL 响应"""
|
||||
authorize_url: str = Field(..., description="OAuth 授权 URL")
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""OAuth 用户信息"""
|
||||
id: str = Field(..., description="用户ID")
|
||||
username: str = Field(..., description="用户名")
|
||||
name: str = Field(..., description="显示名称")
|
||||
email: Optional[str] = Field(default=None, description="邮箱")
|
||||
avatar: Optional[str] = Field(default=None, description="头像URL")
|
||||
user_type: int = Field(default=1, description="用户类型")
|
||||
is_superuser: bool = Field(default=False, description="是否超级管理员")
|
||||
|
||||
|
||||
class OAuthLoginOut(BaseModel):
|
||||
"""OAuth 登录响应"""
|
||||
access_token: str = Field(..., description="访问令牌")
|
||||
refresh_token: str = Field(..., description="刷新令牌")
|
||||
expire: int = Field(..., description="过期时间(秒)")
|
||||
user_info: Dict[str, Any] = Field(..., description="用户信息")
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user