#!/usr/bin/env python # -*- coding: utf-8 -*- """ OAuth Service - OAuth 业务逻辑层 处理第三方 OAuth 登录逻辑(异步版本) """ import json import logging import re from typing import Dict, Optional, Tuple import httpx from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from core.oauth.base_oauth_service import BaseOAuthService from core.user.model import User logger = logging.getLogger(__name__) class GiteeOAuthService(BaseOAuthService): """Gitee OAuth 服务类""" PROVIDER_NAME = 'gitee' AUTHORIZE_URL = "https://gitee.com/oauth/authorize" TOKEN_URL = "https://gitee.com/oauth/token" USER_INFO_URL = "https://gitee.com/api/v5/user" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取 Gitee 客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'GITEE_CLIENT_ID', ''), 'client_secret': getattr(settings, 'GITEE_CLIENT_SECRET', ''), 'redirect_uri': getattr(settings, 'GITEE_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取 Gitee OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_gitee") return { 'client_id': oauth_config.get("client_id") or getattr(settings, 'GITEE_CLIENT_ID', '') or '', 'client_secret': oauth_config.get("client_secret") or getattr(settings, 'GITEE_CLIENT_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'GITEE_REDIRECT_URI', '') or '', } @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """ 使用访问令牌获取 Gitee 用户信息 """ try: params = {'access_token': access_token} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, params=params) response.raise_for_status() user_info = response.json() if 'id' not in user_info: logger.error(f"Gitee 用户信息格式错误: {user_info}") return None return user_info except httpx.RequestError as e: logger.error(f"请求 Gitee 用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取 Gitee 用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化 Gitee 用户信息""" return { 'provider_id': str(raw_user_info.get('id')), 'username': raw_user_info.get('login'), 'name': raw_user_info.get('name', raw_user_info.get('login')), 'email': raw_user_info.get('email'), 'avatar': raw_user_info.get('avatar_url'), 'bio': raw_user_info.get('bio'), } class GitHubOAuthService(BaseOAuthService): """GitHub OAuth 服务类""" PROVIDER_NAME = 'github' AUTHORIZE_URL = "https://github.com/login/oauth/authorize" TOKEN_URL = "https://github.com/login/oauth/access_token" USER_INFO_URL = "https://api.github.com/user" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取 GitHub 客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'GITHUB_CLIENT_ID', ''), 'client_secret': getattr(settings, 'GITHUB_CLIENT_SECRET', ''), 'redirect_uri': getattr(settings, 'GITHUB_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取 GitHub OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_github") return { 'client_id': oauth_config.get("client_id") or getattr(settings, 'GITHUB_CLIENT_ID', '') or '', 'client_secret': oauth_config.get("client_secret") or getattr(settings, 'GITHUB_CLIENT_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'GITHUB_REDIRECT_URI', '') or '', } @classmethod def get_extra_authorize_params(cls) -> Dict[str, str]: """GitHub 需要 scope 参数""" return { 'scope': 'user:email', } @classmethod def get_token_request_headers(cls) -> Dict[str, str]: """GitHub 需要 Accept header 来获取 JSON 响应""" return { 'Accept': 'application/json', } @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """使用访问令牌获取 GitHub 用户信息""" try: headers = { 'Authorization': f'Bearer {access_token}', 'Accept': 'application/json', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, headers=headers) response.raise_for_status() user_info = response.json() if 'id' not in user_info: logger.error(f"GitHub 用户信息格式错误: {user_info}") return None return user_info except httpx.RequestError as e: logger.error(f"请求 GitHub 用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取 GitHub 用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化 GitHub 用户信息""" return { 'provider_id': str(raw_user_info.get('id')), 'username': raw_user_info.get('login'), 'name': raw_user_info.get('name') or raw_user_info.get('login'), 'email': raw_user_info.get('email'), 'avatar': raw_user_info.get('avatar_url'), 'bio': raw_user_info.get('bio'), } class QQOAuthService(BaseOAuthService): """QQ 互联 OAuth 服务类""" PROVIDER_NAME = 'qq' AUTHORIZE_URL = "https://graph.qq.com/oauth2.0/authorize" TOKEN_URL = "https://graph.qq.com/oauth2.0/token" USER_INFO_URL = "https://graph.qq.com/user/get_user_info" OPENID_URL = "https://graph.qq.com/oauth2.0/me" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取 QQ 客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'QQ_APP_ID', ''), 'client_secret': getattr(settings, 'QQ_APP_KEY', ''), 'redirect_uri': getattr(settings, 'QQ_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取 QQ OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_qq") return { 'client_id': oauth_config.get("app_id") or getattr(settings, 'QQ_APP_ID', '') or '', 'client_secret': oauth_config.get("app_key") or getattr(settings, 'QQ_APP_KEY', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'QQ_REDIRECT_URI', '') or '', } @classmethod def get_extra_authorize_params(cls) -> Dict[str, str]: """QQ 需要 response_type 参数""" return { 'response_type': 'code', } @classmethod async def get_access_token(cls, code: str, redirect_uri: Optional[str] = None) -> Optional[str]: """使用授权码获取访问令牌(QQ 返回 URL 参数格式)""" try: config = await cls.get_client_config_async() params = { 'grant_type': 'authorization_code', 'client_id': config['client_id'], 'client_secret': config['client_secret'], 'code': code, 'redirect_uri': redirect_uri or config['redirect_uri'], } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.TOKEN_URL, params=params) response.raise_for_status() # QQ 返回的是 URL 参数格式: access_token=xxx&expires_in=xxx response_text = response.text match = re.search(r'access_token=([^&]+)', response_text) if match: access_token = match.group(1) logger.info(f"QQ access_token 获取成功") return access_token else: logger.error(f"QQ access_token 解析失败: {response_text}") return None except httpx.RequestError as e: logger.error(f"请求 QQ access_token 失败: {str(e)}") return None except Exception as e: logger.error(f"获取 QQ access_token 异常: {str(e)}") return None @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """使用访问令牌获取 QQ 用户信息""" try: async with httpx.AsyncClient(timeout=10.0) as client: # 1. 获取 openid openid_response = await client.get( cls.OPENID_URL, params={'access_token': access_token} ) openid_response.raise_for_status() # QQ 返回的是 JSONP 格式: callback( {"client_id":"xxx","openid":"xxx"} ); openid_text = openid_response.text match = re.search(r'callback\(\s*(\{.*?\})\s*\)', openid_text) if not match: logger.error(f"QQ openid 解析失败: {openid_text}") return None openid_data = json.loads(match.group(1)) openid = openid_data.get('openid') if not openid: logger.error(f"QQ openid 不存在: {openid_data}") return None logger.info(f"QQ openid 获取成功: {openid}") # 2. 获取用户信息 config = await cls.get_client_config_async() user_response = await client.get( cls.USER_INFO_URL, params={ 'access_token': access_token, 'oauth_consumer_key': config['client_id'], 'openid': openid } ) user_response.raise_for_status() user_info = user_response.json() if user_info.get('ret') != 0: logger.error(f"QQ 用户信息获取失败: {user_info.get('msg')}") return None user_info['openid'] = openid return user_info except httpx.RequestError as e: logger.error(f"请求 QQ 用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取 QQ 用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化 QQ 用户信息""" return { 'provider_id': raw_user_info.get('openid'), 'username': raw_user_info.get('nickname', '').replace(' ', '_'), 'name': raw_user_info.get('nickname'), 'email': None, 'avatar': raw_user_info.get('figureurl_qq_2') or raw_user_info.get('figureurl_qq_1'), 'bio': None, } class GoogleOAuthService(BaseOAuthService): """Google OAuth 服务类""" PROVIDER_NAME = 'google' AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth" TOKEN_URL = "https://oauth2.googleapis.com/token" USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取 Google 客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'GOOGLE_CLIENT_ID', ''), 'client_secret': getattr(settings, 'GOOGLE_CLIENT_SECRET', ''), 'redirect_uri': getattr(settings, 'GOOGLE_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取 Google OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_google") return { 'client_id': oauth_config.get("client_id") or getattr(settings, 'GOOGLE_CLIENT_ID', '') or '', 'client_secret': oauth_config.get("client_secret") or getattr(settings, 'GOOGLE_CLIENT_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'GOOGLE_REDIRECT_URI', '') or '', } @classmethod def get_extra_authorize_params(cls) -> Dict[str, str]: """Google 需要 scope 和 access_type 参数""" return { 'scope': 'openid email profile', 'access_type': 'offline', 'response_type': 'code', } @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """使用访问令牌获取 Google 用户信息""" try: headers = { 'Authorization': f'Bearer {access_token}', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, headers=headers) response.raise_for_status() user_info = response.json() if 'id' not in user_info: logger.error(f"Google 用户信息格式错误: {user_info}") return None return user_info except httpx.RequestError as e: logger.error(f"请求 Google 用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取 Google 用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化 Google 用户信息""" return { 'provider_id': raw_user_info.get('id'), 'username': raw_user_info.get('email', '').split('@')[0], 'name': raw_user_info.get('name') or raw_user_info.get('email'), 'email': raw_user_info.get('email'), 'avatar': raw_user_info.get('picture'), 'bio': None, } class WeChatOAuthService(BaseOAuthService): """微信开放平台 OAuth 服务类""" PROVIDER_NAME = 'wechat' AUTHORIZE_URL = "https://open.weixin.qq.com/connect/qrconnect" TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token" USER_INFO_URL = "https://api.weixin.qq.com/sns/userinfo" @classmethod def get_user_id_field(cls) -> str: """微信使用 unionid 作为唯一标识""" return 'wechat_unionid' @classmethod def get_client_config(cls) -> Dict[str, str]: """获取微信客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'WECHAT_APP_ID', ''), 'client_secret': getattr(settings, 'WECHAT_APP_SECRET', ''), 'redirect_uri': getattr(settings, 'WECHAT_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取微信 OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_wechat") return { 'client_id': oauth_config.get("app_id") or getattr(settings, 'WECHAT_APP_ID', '') or '', 'client_secret': oauth_config.get("app_secret") or getattr(settings, 'WECHAT_APP_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'WECHAT_REDIRECT_URI', '') or '', } @classmethod async def get_authorize_url(cls, state: str = None, redirect_uri: str = None) -> str: """获取微信授权 URL(微信参数名称与标准 OAuth 2.0 不同)""" config = await cls.get_client_config_async() params = { 'appid': config['client_id'], 'redirect_uri': redirect_uri or config['redirect_uri'], 'response_type': 'code', 'scope': 'snsapi_login', } if state: params['state'] = state query_string = '&'.join([f"{k}={v}" for k, v in params.items()]) return f"{cls.AUTHORIZE_URL}?{query_string}#wechat_redirect" @classmethod async def get_access_token(cls, code: str) -> Optional[Dict]: """使用授权码获取访问令牌(微信返回 access_token 和 openid)""" try: config = await cls.get_client_config_async() params = { 'appid': config['client_id'], 'secret': config['client_secret'], 'code': code, 'grant_type': 'authorization_code', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.TOKEN_URL, params=params) response.raise_for_status() token_data = response.json() if 'errcode' in token_data: logger.error(f"微信获取 token 失败: {token_data}") return None if 'access_token' not in token_data or 'openid' not in token_data: logger.error(f"微信 token 响应格式错误: {token_data}") return None return token_data except httpx.RequestError as e: logger.error(f"请求微信 token 失败: {str(e)}") return None except Exception as e: logger.error(f"获取微信 token 异常: {str(e)}") return None @classmethod async def get_user_info(cls, access_token: str, openid: str = None) -> Optional[Dict]: """使用访问令牌获取微信用户信息""" try: params = { 'access_token': access_token, 'openid': openid, 'lang': 'zh_CN', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, params=params) response.raise_for_status() user_info = response.json() if 'errcode' in user_info: logger.error(f"微信获取用户信息失败: {user_info}") return None if 'openid' not in user_info: logger.error(f"微信用户信息格式错误: {user_info}") return None return user_info except httpx.RequestError as e: logger.error(f"请求微信用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取微信用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化微信用户信息""" provider_id = raw_user_info.get('unionid') or raw_user_info.get('openid') nickname = raw_user_info.get('nickname', '') username = nickname.replace(' ', '_')[:30] if nickname else f"wechat_{provider_id[:8]}" return { 'provider_id': provider_id, 'username': username, 'name': nickname or username, 'email': None, 'avatar': raw_user_info.get('headimgurl'), 'bio': None, } @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 登录流程(重写基类方法) 微信的 get_access_token 返回 Dict(含 access_token 和 openid), 且 get_user_info 需要同时传递 access_token 和 openid, 因此需要重写此方法以正确处理。 """ # 1. 使用 code 换取 token_data(Dict,含 access_token + openid) token_data = await cls.get_access_token(code) if not token_data: raise ValueError(f"获取 {cls.PROVIDER_NAME} 访问令牌失败") access_token = token_data.get('access_token') openid = token_data.get('openid') if not access_token or not openid: raise ValueError(f"获取 {cls.PROVIDER_NAME} 访问令牌格式错误") # 2. 使用 access_token + openid 获取用户信息 raw_user_info = await cls.get_user_info(access_token, openid=openid) if not raw_user_info: raise ValueError(f"获取 {cls.PROVIDER_NAME} 用户信息失败") # 3. 后续流程与基类一致,调用基类的通用逻辑 # 先标准化用户信息,然后手动执行基类 handle_oauth_login 中第222行之后的逻辑 # 为避免代码重复,将 access_token(str) 设置到类属性后调用基类 # 但基类的 handle_oauth_login 会重新调用 get_access_token,所以这里直接复用基类的用户处理逻辑 return await cls._handle_user_login( db=db, raw_user_info=raw_user_info, ip_address=ip_address, user_agent=user_agent, login_type=login_type, device_id=device_id, ) @classmethod async def _handle_user_login( cls, db: AsyncSession, raw_user_info: Dict, ip_address: str, user_agent: str = None, login_type: str = None, device_id: str = None, ) -> Tuple[User, str, str, int]: """微信专用:从已获取的用户信息开始执行登录流程(复用基类逻辑)""" from datetime import timedelta from sqlalchemy import select from utils.redis import RedisClient from core.login_log.service import LoginLogService from utils.security import create_access_token, create_refresh_token from utils.user_info_cache import set_cached_user_info from core.user.service import UserService from core.oauth.base_oauth_service import REFRESH_TOKEN_PREFIX # 标准化用户信息 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') # 查找或创建用户 user_id_field = cls.get_user_id_field() 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() 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("账户已被锁定,请联系管理员") # 生成 JWT 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 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 redis = await RedisClient.get_client() if not settings.ALLOW_MULTI_DEVICE_LOGIN: refresh_pattern = f"{REFRESH_TOKEN_PREFIX}{user.id}:*" access_pattern = f"access_token:{user.id}:*" 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 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 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())) if device_id: await redis.set( f"access_token:{user.id}:{device_id}", jwt_access_token, ex=int(access_token_expires.total_seconds()) ) 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 class MicrosoftOAuthService(BaseOAuthService): """微软 OAuth 服务类""" PROVIDER_NAME = 'microsoft' AUTHORIZE_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" USER_INFO_URL = "https://graph.microsoft.com/v1.0/me" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取微软客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'MICROSOFT_CLIENT_ID', ''), 'client_secret': getattr(settings, 'MICROSOFT_CLIENT_SECRET', ''), 'redirect_uri': getattr(settings, 'MICROSOFT_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取微软 OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_microsoft") return { 'client_id': oauth_config.get("client_id") or getattr(settings, 'MICROSOFT_CLIENT_ID', '') or '', 'client_secret': oauth_config.get("client_secret") or getattr(settings, 'MICROSOFT_CLIENT_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'MICROSOFT_REDIRECT_URI', '') or '', } @classmethod def get_extra_authorize_params(cls) -> Dict[str, str]: """微软需要 scope 和 response_mode 参数""" return { 'scope': 'openid email profile User.Read', 'response_type': 'code', 'response_mode': 'query', } @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """使用 Microsoft Graph API 获取用户信息""" try: headers = { 'Authorization': f'Bearer {access_token}', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, headers=headers) response.raise_for_status() user_info = response.json() if 'id' not in user_info: logger.error(f"Microsoft 用户信息格式错误: {user_info}") return None return user_info except httpx.RequestError as e: logger.error(f"请求 Microsoft 用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取 Microsoft 用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化微软用户信息""" user_principal_name = raw_user_info.get('userPrincipalName', '') username = user_principal_name.split('@')[0] if '@' in user_principal_name else user_principal_name email = raw_user_info.get('mail') or raw_user_info.get('userPrincipalName') return { 'provider_id': raw_user_info.get('id'), 'username': username or f"ms_{raw_user_info.get('id', '')[:8]}", 'name': raw_user_info.get('displayName') or username, 'email': email, 'avatar': None, 'bio': raw_user_info.get('jobTitle'), } class DingTalkOAuthService(BaseOAuthService): """钉钉 OAuth 服务类""" PROVIDER_NAME = 'dingtalk' AUTHORIZE_URL = "https://login.dingtalk.com/oauth2/auth" TOKEN_URL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken" USER_INFO_URL = "https://api.dingtalk.com/v1.0/contact/users/me" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取钉钉客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'DINGTALK_APP_ID', ''), 'client_secret': getattr(settings, 'DINGTALK_APP_SECRET', ''), 'redirect_uri': getattr(settings, 'DINGTALK_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取钉钉 OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_dingtalk") return { 'client_id': oauth_config.get("app_id") or getattr(settings, 'DINGTALK_APP_ID', '') or '', 'client_secret': oauth_config.get("app_secret") or getattr(settings, 'DINGTALK_APP_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'DINGTALK_REDIRECT_URI', '') or '', } @classmethod def get_extra_authorize_params(cls) -> Dict[str, str]: """钉钉需要的额外授权参数""" return { 'response_type': 'code', 'scope': 'openid', 'prompt': 'consent', } @classmethod async def get_access_token(cls, code: str) -> Optional[str]: """使用授权码获取访问令牌(钉钉使用 JSON body)""" try: config = await cls.get_client_config_async() data = { 'clientId': config['client_id'], 'clientSecret': config['client_secret'], 'code': code, 'grantType': 'authorization_code', } headers = { 'Content-Type': 'application/json', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(cls.TOKEN_URL, json=data, headers=headers) response.raise_for_status() result = response.json() if 'accessToken' in result: logger.info(f"钉钉 access_token 获取成功") return result['accessToken'] else: logger.error(f"钉钉 token 响应格式错误: {result}") return None except httpx.RequestError as e: logger.error(f"请求钉钉 access_token 失败: {str(e)}") return None except Exception as e: logger.error(f"获取钉钉 access_token 异常: {str(e)}") return None @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """使用访问令牌获取钉钉用户信息""" try: headers = { 'x-acs-dingtalk-access-token': access_token, 'Content-Type': 'application/json', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, headers=headers) response.raise_for_status() user_info = response.json() if 'unionId' not in user_info: logger.error(f"钉钉用户信息格式错误: {user_info}") return None return user_info except httpx.RequestError as e: logger.error(f"请求钉钉用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取钉钉用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化钉钉用户信息""" provider_id = raw_user_info.get('unionId', '') nick = raw_user_info.get('nick', '') username = nick if nick else f"dingtalk_{provider_id[:8]}" return { 'provider_id': provider_id, 'username': username, 'name': nick or username, 'email': raw_user_info.get('email'), 'avatar': raw_user_info.get('avatarUrl'), 'mobile': raw_user_info.get('mobile'), 'bio': f"钉钉用户 - {nick}" if nick else "钉钉用户", } @classmethod def get_user_id_field(cls) -> str: """获取用户 ID 字段名""" return 'dingtalk_unionid' class FeishuOAuthService(BaseOAuthService): """飞书 OAuth 服务类""" PROVIDER_NAME = 'feishu' AUTHORIZE_URL = "https://open.feishu.cn/open-apis/authen/v1/authorize" TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token" USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info" @classmethod def get_client_config(cls) -> Dict[str, str]: """获取飞书客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'FEISHU_APP_ID', ''), 'client_secret': getattr(settings, 'FEISHU_APP_SECRET', ''), 'redirect_uri': getattr(settings, 'FEISHU_REDIRECT_URI', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取飞书 OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_feishu") return { 'client_id': oauth_config.get("app_id") or getattr(settings, 'FEISHU_APP_ID', '') or '', 'client_secret': oauth_config.get("app_secret") or getattr(settings, 'FEISHU_APP_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'FEISHU_REDIRECT_URI', '') or '', } @classmethod def get_extra_authorize_params(cls) -> Dict[str, str]: """飞书需要的额外授权参数""" return { 'response_type': 'code', 'scope': 'contact:user.base:readonly', } @classmethod async def _get_app_access_token(cls) -> Optional[str]: """获取应用级别的 access_token""" try: config = await cls.get_client_config_async() url = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal" data = { 'app_id': config['client_id'], 'app_secret': config['client_secret'], } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(url, json=data) response.raise_for_status() result = response.json() if result.get('code') == 0: return result.get('app_access_token') logger.error(f"获取飞书 app_access_token 失败: {result}") return None except Exception as e: logger.error(f"获取飞书 app_access_token 异常: {str(e)}") return None @classmethod async def get_access_token(cls, code: str) -> Optional[str]: """使用授权码获取访问令牌""" try: app_access_token = await cls._get_app_access_token() if not app_access_token: return None data = { 'grant_type': 'authorization_code', 'code': code, } headers = { 'Content-Type': 'application/json', 'Authorization': f'Bearer {app_access_token}', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(cls.TOKEN_URL, json=data, headers=headers) response.raise_for_status() result = response.json() if result.get('code') == 0 and 'data' in result: access_token = result['data'].get('access_token') if access_token: logger.info(f"飞书 access_token 获取成功") return access_token logger.error(f"飞书 token 响应格式错误: {result}") return None except httpx.RequestError as e: logger.error(f"请求飞书 access_token 失败: {str(e)}") return None except Exception as e: logger.error(f"获取飞书 access_token 异常: {str(e)}") return None @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """使用访问令牌获取飞书用户信息""" try: headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, headers=headers) response.raise_for_status() result = response.json() if result.get('code') == 0 and 'data' in result: user_info = result['data'] if 'union_id' not in user_info: logger.error(f"飞书用户信息格式错误: {result}") return None return user_info logger.error(f"飞书用户信息响应错误: {result}") return None except httpx.RequestError as e: logger.error(f"请求飞书用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取飞书用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化飞书用户信息""" provider_id = raw_user_info.get('union_id', '') name = raw_user_info.get('name', '') en_name = raw_user_info.get('en_name', '') username = en_name or name or f"feishu_{provider_id[:8]}" username = username.replace(' ', '_') mobile = raw_user_info.get('mobile', '') if mobile and mobile.startswith('+86-'): mobile = mobile[4:] return { 'provider_id': provider_id, 'username': username, 'name': name or username, 'email': raw_user_info.get('email'), 'avatar': raw_user_info.get('avatar_url') or raw_user_info.get('avatar_big'), 'mobile': mobile, 'bio': f"飞书用户 - {name}" if name else "飞书用户", } @classmethod def get_user_id_field(cls) -> str: """获取用户 ID 字段名""" return 'feishu_union_id' class WeComOAuthService(BaseOAuthService): """企业微信 OAuth 服务类""" PROVIDER_NAME = 'wecom' AUTHORIZE_URL = "https://login.work.weixin.qq.com/wwlogin/sso/login" TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo" USER_DETAIL_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get" @classmethod def get_user_id_field(cls) -> str: """企业微信使用 wecom_userid 作为唯一标识""" return 'wecom_userid' @classmethod def get_client_config(cls) -> Dict[str, str]: """获取企业微信客户端配置(同步回退)""" return { 'client_id': getattr(settings, 'WECOM_CORP_ID', ''), 'client_secret': getattr(settings, 'WECOM_APP_SECRET', ''), 'redirect_uri': getattr(settings, 'WECOM_REDIRECT_URI', ''), 'agent_id': getattr(settings, 'WECOM_AGENT_ID', ''), } @classmethod async def get_client_config_async(cls) -> Dict[str, str]: """从 config_manager 三级配置获取企业微信 OAuth 配置""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_wecom") return { 'client_id': oauth_config.get("corp_id") or getattr(settings, 'WECOM_CORP_ID', '') or '', 'client_secret': oauth_config.get("app_secret") or getattr(settings, 'WECOM_APP_SECRET', '') or '', 'redirect_uri': oauth_config.get("redirect_uri") or getattr(settings, 'WECOM_REDIRECT_URI', '') or '', 'agent_id': oauth_config.get("agent_id") or getattr(settings, 'WECOM_AGENT_ID', '') or '', } @classmethod async def get_authorize_url(cls, state: str = None, redirect_uri: str = None) -> str: """获取企业微信授权 URL(企业微信使用特殊的 SSO 登录页面)""" config = await cls.get_client_config_async() params = { 'login_type': 'CorpApp', 'appid': config['client_id'], 'agentid': config['agent_id'], 'redirect_uri': redirect_uri or config['redirect_uri'], } if state: params['state'] = state query_string = '&'.join([f"{k}={v}" for k, v in params.items()]) return f"{cls.AUTHORIZE_URL}?{query_string}" @classmethod async def _get_corp_access_token(cls) -> Optional[str]: """获取企业微信的企业级 access_token""" try: config = await cls.get_client_config_async() params = { 'corpid': config['client_id'], 'corpsecret': config['client_secret'], } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.TOKEN_URL, params=params) response.raise_for_status() result = response.json() if result.get('errcode') == 0 and 'access_token' in result: logger.info("企业微信 corp access_token 获取成功") return result['access_token'] logger.error(f"获取企业微信 corp access_token 失败: {result}") return None except Exception as e: logger.error(f"获取企业微信 corp access_token 异常: {str(e)}") return None @classmethod async def get_access_token(cls, code: str) -> Optional[str]: """ 企业微信不使用标准的 code 换 token 流程。 这里获取 corp access_token 并返回,code 在 get_user_info 中使用。 返回格式: "corp_access_token|code" 以便后续使用。 """ corp_token = await cls._get_corp_access_token() if not corp_token: return None # 将 corp_token 和 code 拼接传递,在 get_user_info 中拆分使用 return f"{corp_token}|{code}" @classmethod async def get_user_info(cls, access_token: str) -> Optional[Dict]: """ 使用 corp access_token + code 获取企业微信用户信息。 access_token 格式: "corp_access_token|code" """ try: parts = access_token.split('|', 1) if len(parts) != 2: logger.error("企业微信 access_token 格式错误") return None corp_token, code = parts # 第一步:用 code 获取 userid params = { 'access_token': corp_token, 'code': code, } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(cls.USER_INFO_URL, params=params) response.raise_for_status() result = response.json() if result.get('errcode') != 0: logger.error(f"企业微信获取用户身份失败: {result}") return None userid = result.get('userid') or result.get('UserId') if not userid: logger.error(f"企业微信用户身份响应中无 userid: {result}") return None # 第二步:用 userid 获取用户详情 detail_params = { 'access_token': corp_token, 'userid': userid, } detail_response = await client.get(cls.USER_DETAIL_URL, params=detail_params) detail_response.raise_for_status() user_detail = detail_response.json() if user_detail.get('errcode') != 0: logger.error(f"企业微信获取用户详情失败: {user_detail}") # 即使获取详情失败,也返回基本信息 return { 'userid': userid, 'name': userid, } return user_detail except httpx.RequestError as e: logger.error(f"请求企业微信用户信息失败: {str(e)}") return None except Exception as e: logger.error(f"获取企业微信用户信息异常: {str(e)}") return None @classmethod def normalize_user_info(cls, raw_user_info: Dict) -> Dict: """标准化企业微信用户信息""" userid = raw_user_info.get('userid', '') name = raw_user_info.get('name', '') username = userid if userid else f"wecom_{name}" email = raw_user_info.get('biz_mail') or raw_user_info.get('email') avatar = raw_user_info.get('thumb_avatar') or raw_user_info.get('avatar') mobile = raw_user_info.get('mobile', '') position = raw_user_info.get('position', '') return { 'provider_id': userid, 'username': username, 'name': name or username, 'email': email, 'avatar': avatar, 'bio': f"企业微信用户 - {position}" if position else "企业微信用户", } # OAuth 提供商映射 OAUTH_PROVIDERS = { 'gitee': GiteeOAuthService, 'github': GitHubOAuthService, 'qq': QQOAuthService, 'google': GoogleOAuthService, 'wechat': WeChatOAuthService, 'microsoft': MicrosoftOAuthService, 'dingtalk': DingTalkOAuthService, 'feishu': FeishuOAuthService, 'wecom': WeComOAuthService, }