#!/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