2026-01-30 12:43:08 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-01-19 03:19:17 +08:00
|
|
|
|
from abc import ABC, abstractmethod
|
2026-03-31 19:19:04 +08:00
|
|
|
|
from typing import TYPE_CHECKING
|
2026-01-19 03:19:17 +08:00
|
|
|
|
from urllib.parse import urlencode, urlparse, urlunparse
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
from src.services.auth.oauth.models import OAuthFlowError, OAuthToken, OAuthUserInfo
|
2026-01-19 03:19:17 +08:00
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
|
from src.models.database import OAuthProvider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OAuthProviderBase(ABC):
|
|
|
|
|
|
"""
|
|
|
|
|
|
OAuth Provider 基类(稳定扩展点)。
|
|
|
|
|
|
|
|
|
|
|
|
v1 收敛点:仅实现 OAuth2 授权码流程所需的最小接口。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
provider_type: str
|
|
|
|
|
|
display_name: str
|
|
|
|
|
|
|
|
|
|
|
|
# 允许的 host 白名单(用于端点覆盖校验,支持子域名)
|
|
|
|
|
|
allowed_domains: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
authorization_url: str
|
|
|
|
|
|
token_url: str
|
|
|
|
|
|
userinfo_url: str
|
|
|
|
|
|
default_scopes: tuple[str, ...] = ()
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_effective_authorization_url(self, config: OAuthProvider) -> str:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
return config.authorization_url_override or self.authorization_url
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_effective_token_url(self, config: OAuthProvider) -> str:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
return config.token_url_override or self.token_url
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_effective_userinfo_url(self, config: OAuthProvider) -> str:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
return config.userinfo_url_override or self.userinfo_url
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_effective_scopes(self, config: OAuthProvider) -> str:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
scopes = config.scopes or list(self.default_scopes)
|
|
|
|
|
|
return " ".join(scopes)
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_authorization_url(self, config: OAuthProvider, state: str) -> str:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
"""
|
|
|
|
|
|
构造 provider 授权 URL。
|
|
|
|
|
|
|
|
|
|
|
|
redirect_uri 必须由服务端控制,不从客户端传入。
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = self.get_effective_authorization_url(config)
|
|
|
|
|
|
# 避免覆盖原有 query(若 provider 默认 url 带 query,保留)
|
|
|
|
|
|
parsed = urlparse(base)
|
|
|
|
|
|
query: dict[str, str] = {}
|
|
|
|
|
|
if parsed.query:
|
|
|
|
|
|
# 保留已有 query 参数
|
|
|
|
|
|
for kv in parsed.query.split("&"):
|
|
|
|
|
|
if not kv:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "=" in kv:
|
|
|
|
|
|
k, v = kv.split("=", 1)
|
|
|
|
|
|
query[k] = v
|
|
|
|
|
|
else:
|
|
|
|
|
|
query[kv] = ""
|
|
|
|
|
|
|
|
|
|
|
|
client_id = config.client_id
|
|
|
|
|
|
redirect_uri = config.redirect_uri
|
|
|
|
|
|
if not client_id or not redirect_uri:
|
|
|
|
|
|
raise ValueError("OAuthProvider 配置不完整:client_id/redirect_uri 不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
query.update(
|
|
|
|
|
|
{
|
|
|
|
|
|
"response_type": "code",
|
|
|
|
|
|
"client_id": client_id,
|
|
|
|
|
|
"redirect_uri": redirect_uri,
|
|
|
|
|
|
"state": state,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-03-19 20:32:33 +08:00
|
|
|
|
scopes = self.get_effective_scopes(config)
|
|
|
|
|
|
if scopes:
|
|
|
|
|
|
query["scope"] = scopes
|
2026-01-19 03:19:17 +08:00
|
|
|
|
|
|
|
|
|
|
return urlunparse(parsed._replace(query=urlencode(query)))
|
|
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
async def exchange_code(self, config: OAuthProvider, code: str) -> OAuthToken:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
"""使用授权码兑换 token。"""
|
|
|
|
|
|
|
|
|
|
|
|
@abstractmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
async def get_user_info(self, config: OAuthProvider, access_token: str) -> OAuthUserInfo:
|
2026-01-19 03:19:17 +08:00
|
|
|
|
"""获取用户信息。"""
|
|
|
|
|
|
|
|
|
|
|
|
async def _http_post_form(
|
|
|
|
|
|
self,
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
data: dict[str, str],
|
|
|
|
|
|
*,
|
|
|
|
|
|
timeout_seconds: float = 5.0,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
headers: dict[str, str] | None = None,
|
2026-01-19 03:19:17 +08:00
|
|
|
|
) -> httpx.Response:
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")
|
2026-01-19 03:19:17 +08:00
|
|
|
|
|
|
|
|
|
|
async def _http_get(
|
|
|
|
|
|
self,
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
timeout_seconds: float = 5.0,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
headers: dict[str, str] | None = None,
|
2026-01-19 03:19:17 +08:00
|
|
|
|
) -> httpx.Response:
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise OAuthFlowError("provider_unavailable", "OAuth 仅支持 Rust executor")
|