#!/usr/bin/env python
# coding: utf-8
"""
OAuth2 提供商
提供主流 OAuth2 提供商的实现,包括 GitHub、Google、微信等。
"""
import base64
import hashlib
import secrets
import urllib.parse
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, Optional
[文档]
@dataclass
class OAuth2UserInfo:
"""OAuth2 用户信息"""
provider: str
provider_user_id: str
username: str
email: Optional[str] = None
name: Optional[str] = None
avatar_url: Optional[str] = None
raw_data: Optional[Dict[str, Any]] = None
[文档]
def to_dict(self) -> Dict[str, Any]:
return {
'provider': self.provider,
'provider_user_id': self.provider_user_id,
'username': self.username,
'email': self.email,
'name': self.name,
'avatar_url': self.avatar_url,
}
[文档]
class OAuth2Provider(ABC):
"""OAuth2 提供商基类"""
name: str = 'base'
authorize_url: str = ''
access_token_url: str = ''
userinfo_url: str = ''
scope: str = ''
def __init__(
self,
client_id: str,
client_secret: str,
redirect_uri: str = '',
scope: str = None,
**kwargs
):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.scope = scope or self.scope
self.extra_params = kwargs
[文档]
def get_authorize_url(
self,
state: str,
redirect_uri: str = None,
**kwargs
) -> str:
"""
生成授权 URL
Args:
state: 状态参数,用于防止 CSRF 攻击
redirect_uri: 回调地址
**kwargs: 额外参数
Returns:
授权 URL
"""
params = {
'client_id': self.client_id,
'redirect_uri': redirect_uri or self.redirect_uri,
'response_type': 'code',
'state': state,
}
if self.scope:
params['scope'] = self.scope
params.update(kwargs)
return f"{self.authorize_url}?{urllib.parse.urlencode(params)}"
[文档]
@abstractmethod
def get_access_token(
self,
code: str,
redirect_uri: str = None
) -> Dict[str, Any]:
"""
使用授权码获取 Access Token
Args:
code: 授权码
redirect_uri: 回调地址
Returns:
Token 信息
"""
pass
[文档]
@abstractmethod
def get_user_info(self, access_token: str) -> OAuth2UserInfo:
"""
获取用户信息
Args:
access_token: Access Token
Returns:
用户信息
"""
pass
def _request(
self,
url: str,
method: str = 'GET',
data: Dict = None,
headers: Dict = None
) -> Dict[str, Any]:
"""
发送 HTTP 请求(同步)
Args:
url: 请求 URL
method: 请求方法
data: 请求数据
headers: 请求头
Returns:
响应数据
"""
import json
try:
import urllib.request
import urllib.error
req_headers = {
'Accept': 'application/json',
'User-Agent': 'Litefs-OAuth2/1.0',
}
if headers:
req_headers.update(headers)
if method == 'GET':
if data:
url = f"{url}?{urllib.parse.urlencode(data)}"
req = urllib.request.Request(url, headers=req_headers)
else:
body = urllib.parse.urlencode(data).encode('utf-8') if data else b''
req = urllib.request.Request(url, data=body, headers=req_headers, method=method)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8') if e.fp else ''
return {'error': f'HTTP {e.code}', 'error_description': error_body}
except Exception as e:
return {'error': str(e)}
async def _async_request(
self,
url: str,
method: str = 'GET',
data: Dict = None,
headers: Dict = None
) -> Dict[str, Any]:
"""
发送 HTTP 请求(异步)
需要 aiohttp 库,如果未安装则回退到同步请求。
Args:
url: 请求 URL
method: 请求方法
data: 请求数据
headers: 请求头
Returns:
响应数据
"""
import logging
try:
import aiohttp
except ImportError:
logging.getLogger(__name__).warning(
"aiohttp is not installed, falling back to synchronous OAuth2 request. "
"Install aiohttp for async support: pip install aiohttp"
)
return self._request(url, method=method, data=data, headers=headers)
req_headers = {
'Accept': 'application/json',
'User-Agent': 'Litefs-OAuth2/1.0',
}
if headers:
req_headers.update(headers)
try:
async with aiohttp.ClientSession() as session:
if method == 'GET':
if data:
url = f"{url}?{urllib.parse.urlencode(data)}"
async with session.get(
url, headers=req_headers, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
return await resp.json(content_type=None)
else:
async with session.request(
method, url, data=data, headers=req_headers,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
return await resp.json(content_type=None)
except Exception as e:
return {'error': str(e)}
[文档]
def generate_state(self) -> str:
"""生成随机状态参数"""
return secrets.token_urlsafe(32)
[文档]
def generate_pkce_verifier(self) -> str:
"""生成 PKCE code_verifier"""
return secrets.token_urlsafe(64)
[文档]
def generate_pkce_challenge(self, verifier: str, method: str = 'S256') -> str:
"""
生成 PKCE code_challenge
Args:
verifier: code_verifier
method: 挑战方法 ('S256' 或 'plain')
Returns:
code_challenge
"""
if method == 'S256':
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
return verifier
[文档]
class GitHubProvider(OAuth2Provider):
"""GitHub OAuth2 提供商"""
name = 'github'
authorize_url = 'https://github.com/login/oauth/authorize'
access_token_url = 'https://github.com/login/oauth/access_token'
userinfo_url = 'https://api.github.com/user'
scope = 'user:email'
[文档]
def get_access_token(
self,
code: str,
redirect_uri: str = None
) -> Dict[str, Any]:
data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': redirect_uri or self.redirect_uri,
}
result = self._request(
self.access_token_url,
method='POST',
data=data,
headers={'Accept': 'application/json'}
)
if 'error' in result:
return result
return result
[文档]
def get_user_info(self, access_token: str) -> OAuth2UserInfo:
headers = {
'Authorization': f'Bearer {access_token}',
'Accept': 'application/vnd.github.v3+json',
}
user_data = self._request(self.userinfo_url, headers=headers)
if 'error' in user_data:
return None
emails = self._request(
'https://api.github.com/user/emails',
headers=headers
)
primary_email = None
if isinstance(emails, list):
for email_info in emails:
if email_info.get('primary') and email_info.get('verified'):
primary_email = email_info.get('email')
break
return OAuth2UserInfo(
provider=self.name,
provider_user_id=str(user_data.get('id', '')),
username=user_data.get('login', ''),
email=primary_email or user_data.get('email'),
name=user_data.get('name') or user_data.get('login'),
avatar_url=user_data.get('avatar_url'),
raw_data=user_data,
)
[文档]
class GoogleProvider(OAuth2Provider):
"""Google OAuth2 提供商"""
name = 'google'
authorize_url = 'https://accounts.google.com/o/oauth2/v2/auth'
access_token_url = 'https://oauth2.googleapis.com/token'
userinfo_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
scope = 'openid email profile'
[文档]
def get_access_token(
self,
code: str,
redirect_uri: str = None
) -> Dict[str, Any]:
data = {
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': redirect_uri or self.redirect_uri,
'grant_type': 'authorization_code',
}
result = self._request(
self.access_token_url,
method='POST',
data=data
)
return result
[文档]
def get_user_info(self, access_token: str) -> OAuth2UserInfo:
headers = {
'Authorization': f'Bearer {access_token}',
}
user_data = self._request(self.userinfo_url, headers=headers)
if 'error' in user_data:
return None
return OAuth2UserInfo(
provider=self.name,
provider_user_id=str(user_data.get('sub', '')),
username=user_data.get('email', '').split('@')[0],
email=user_data.get('email'),
name=user_data.get('name'),
avatar_url=user_data.get('picture'),
raw_data=user_data,
)
[文档]
class WeChatProvider(OAuth2Provider):
"""微信 OAuth2 提供商"""
name = 'wechat'
authorize_url = 'https://open.weixin.qq.com/connect/qrconnect'
access_token_url = 'https://api.weixin.qq.com/sns/oauth2/access_token'
userinfo_url = 'https://api.weixin.qq.com/sns/userinfo'
scope = 'snsapi_login'
[文档]
def get_authorize_url(
self,
state: str,
redirect_uri: str = None,
**kwargs
) -> str:
params = {
'appid': self.client_id,
'redirect_uri': redirect_uri or self.redirect_uri,
'response_type': 'code',
'scope': self.scope,
'state': state,
}
return f"{self.authorize_url}?{urllib.parse.urlencode(params)}#wechat_redirect"
[文档]
def get_access_token(
self,
code: str,
redirect_uri: str = None
) -> Dict[str, Any]:
params = {
'appid': self.client_id,
'secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
}
url = f"{self.access_token_url}?{urllib.parse.urlencode(params)}"
result = self._request(url)
return result
[文档]
def get_user_info(self, access_token: str, openid: str = None) -> OAuth2UserInfo:
if not openid:
return None
params = {
'access_token': access_token,
'openid': openid,
}
url = f"{self.userinfo_url}?{urllib.parse.urlencode(params)}"
user_data = self._request(url)
if 'errcode' in user_data:
return None
return OAuth2UserInfo(
provider=self.name,
provider_user_id=str(user_data.get('unionid') or user_data.get('openid', '')),
username=user_data.get('nickname', ''),
name=user_data.get('nickname'),
avatar_url=user_data.get('headimgurl'),
raw_data=user_data,
)
[文档]
class WeComProvider(OAuth2Provider):
"""企业微信 OAuth2 提供商"""
name = 'wecom'
authorize_url = 'https://open.work.weixin.qq.com/wwopen/sso/qrConnect'
access_token_url = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'
userinfo_url = 'https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo'
scope = 'snsapi_base'
def __init__(
self,
client_id: str,
client_secret: str,
agent_id: str = '',
redirect_uri: str = '',
**kwargs
):
super().__init__(client_id, client_secret, redirect_uri, **kwargs)
self.agent_id = agent_id
[文档]
def get_authorize_url(
self,
state: str,
redirect_uri: str = None,
**kwargs
) -> str:
params = {
'appid': self.client_id,
'agentid': self.agent_id,
'redirect_uri': redirect_uri or self.redirect_uri,
'response_type': 'code',
'scope': self.scope,
'state': state,
}
return f"{self.authorize_url}?{urllib.parse.urlencode(params)}"
[文档]
def get_access_token(
self,
code: str,
redirect_uri: str = None
) -> Dict[str, Any]:
params = {
'corpid': self.client_id,
'corpsecret': self.client_secret,
}
url = f"{self.access_token_url}?{urllib.parse.urlencode(params)}"
result = self._request(url)
if 'errcode' in result and result['errcode'] != 0:
return result
return result
[文档]
def get_user_info(self, access_token: str, code: str = None) -> OAuth2UserInfo:
if not code:
return None
params = {
'access_token': access_token,
'code': code,
}
url = f"{self.userinfo_url}?{urllib.parse.urlencode(params)}"
user_data = self._request(url)
if 'errcode' in user_data and user_data['errcode'] != 0:
return None
return OAuth2UserInfo(
provider=self.name,
provider_user_id=str(user_data.get('UserId', '')),
username=user_data.get('UserId', ''),
name=user_data.get('name'),
raw_data=user_data,
)
PROVIDERS = {
'github': GitHubProvider,
'google': GoogleProvider,
'wechat': WeChatProvider,
'wecom': WeComProvider,
}
[文档]
def get_provider(name: str) -> Optional[type]:
"""获取提供商类"""
return PROVIDERS.get(name.lower())