|
|
"""
|
|
|
第三方 OAuth2 登录(微信开放平台扫码、QQ、GitHub、Gitee)。
|
|
|
|
|
|
各平台需在开放平台登记「授权回调域 / redirect_uri」为:
|
|
|
{API}/system/auth/oauth/{provider}/callback
|
|
|
例如:https://your-domain.com/api/v1/system/auth/oauth/github/callback
|
|
|
|
|
|
环境变量见 Settings 中 OAUTH_* 字段。
|
|
|
"""
|
|
|
|
|
|
import json
|
|
|
import secrets
|
|
|
from typing import Any, Literal
|
|
|
from urllib.parse import quote, urlencode
|
|
|
|
|
|
import httpx
|
|
|
from fastapi import Request
|
|
|
from redis.asyncio.client import Redis
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
from app.api.v1.module_system.user.crud import UserCRUD
|
|
|
from app.api.v1.module_system.user.model import UserModel
|
|
|
from app.api.v1.module_system.user.schema import UserRegisterSchema
|
|
|
from app.api.v1.module_system.user.service import UserService
|
|
|
from app.config.setting import settings
|
|
|
from app.core.base_schema import AuthSchema, JWTOutSchema
|
|
|
from app.core.exceptions import CustomException
|
|
|
from app.core.logger import logger
|
|
|
from app.core.redis_crud import RedisCURD
|
|
|
|
|
|
from .service import LoginService
|
|
|
|
|
|
OAuthProvider = Literal["wechat", "qq", "github", "gitee"]
|
|
|
|
|
|
STATE_PREFIX = "oauth_state:"
|
|
|
STATE_TTL_SECONDS = 600
|
|
|
|
|
|
|
|
|
def _callback_url(request: Request, provider: OAuthProvider) -> str:
|
|
|
root = str(request.base_url).rstrip("/")
|
|
|
return f"{root}/system/auth/oauth/{provider}/callback"
|
|
|
|
|
|
|
|
|
def _frontend_error_redirect(frontend_base: str, message: str) -> str:
|
|
|
sep = "&" if "?" in frontend_base else "?"
|
|
|
return f"{frontend_base}{sep}oauth_error={quote(message, safe='')}"
|
|
|
|
|
|
|
|
|
def _frontend_success_redirect(frontend_base: str, access_token: str, refresh_token: str, token_type: str) -> str:
|
|
|
q = urlencode(
|
|
|
{
|
|
|
"access_token": access_token,
|
|
|
"refresh_token": refresh_token,
|
|
|
"token_type": token_type,
|
|
|
}
|
|
|
)
|
|
|
sep = "&" if "?" in frontend_base else "?"
|
|
|
return f"{frontend_base}{sep}{q}"
|
|
|
|
|
|
|
|
|
def _require_credentials(provider: OAuthProvider) -> tuple[str, str]:
|
|
|
if provider == "github":
|
|
|
cid, sec = settings.OAUTH_GITHUB_CLIENT_ID, settings.OAUTH_GITHUB_CLIENT_SECRET
|
|
|
elif provider == "gitee":
|
|
|
cid, sec = settings.OAUTH_GITEE_CLIENT_ID, settings.OAUTH_GITEE_CLIENT_SECRET
|
|
|
elif provider == "wechat":
|
|
|
cid, sec = settings.OAUTH_WECHAT_OPEN_APP_ID, settings.OAUTH_WECHAT_OPEN_APP_SECRET
|
|
|
elif provider == "qq":
|
|
|
cid, sec = settings.OAUTH_QQ_APP_ID, settings.OAUTH_QQ_APP_SECRET
|
|
|
else:
|
|
|
raise CustomException(msg="不支持的 OAuth 渠道")
|
|
|
if not cid or not sec:
|
|
|
raise CustomException(msg=f"{provider} OAuth 未配置(客户端密钥为空)")
|
|
|
return cid, sec
|
|
|
|
|
|
|
|
|
def build_authorize_url(
|
|
|
*,
|
|
|
provider: OAuthProvider,
|
|
|
callback_url: str,
|
|
|
state: str,
|
|
|
) -> str:
|
|
|
"""构造跳转至第三方授权页的 URL。"""
|
|
|
cid, _ = _require_credentials(provider)
|
|
|
|
|
|
if provider == "github":
|
|
|
params = {
|
|
|
"client_id": cid,
|
|
|
"redirect_uri": callback_url,
|
|
|
"scope": "user:email",
|
|
|
"state": state,
|
|
|
}
|
|
|
return "https://github.com/login/oauth/authorize?" + urlencode(params)
|
|
|
|
|
|
if provider == "gitee":
|
|
|
params = {
|
|
|
"client_id": cid,
|
|
|
"redirect_uri": callback_url,
|
|
|
"response_type": "code",
|
|
|
"state": state,
|
|
|
}
|
|
|
return "https://gitee.com/oauth/authorize?" + urlencode(params)
|
|
|
|
|
|
if provider == "wechat":
|
|
|
params = {
|
|
|
"appid": cid,
|
|
|
"redirect_uri": callback_url,
|
|
|
"response_type": "code",
|
|
|
"scope": "snsapi_login",
|
|
|
"state": state,
|
|
|
}
|
|
|
return "https://open.weixin.qq.com/connect/qrconnect?" + urlencode(params) + "#wechat_redirect"
|
|
|
|
|
|
if provider == "qq":
|
|
|
params = {
|
|
|
"response_type": "code",
|
|
|
"client_id": cid,
|
|
|
"redirect_uri": callback_url,
|
|
|
"state": state,
|
|
|
"scope": "get_user_info",
|
|
|
}
|
|
|
return "https://graph.qq.com/oauth2.0/authorize?" + urlencode(params)
|
|
|
|
|
|
raise CustomException(msg="不支持的 OAuth 渠道")
|
|
|
|
|
|
|
|
|
async def _http_json(method: str, url: str, **kwargs: Any) -> Any:
|
|
|
timeout = getattr(settings, "HTTPX_DEFAULT_TIMEOUT", 15.0)
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
|
r = await client.request(method, url, **kwargs)
|
|
|
r.raise_for_status()
|
|
|
try:
|
|
|
return r.json()
|
|
|
except json.JSONDecodeError:
|
|
|
text = r.text
|
|
|
logger.error(f"OAuth 非 JSON 响应: {text[:500]}")
|
|
|
raise CustomException(msg="OAuth 接口返回异常")
|
|
|
|
|
|
|
|
|
async def _http_text(method: str, url: str, **kwargs: Any) -> str:
|
|
|
timeout = getattr(settings, "HTTPX_DEFAULT_TIMEOUT", 15.0)
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
|
r = await client.request(method, url, **kwargs)
|
|
|
r.raise_for_status()
|
|
|
return r.text
|
|
|
|
|
|
|
|
|
async def exchange_github_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> str:
|
|
|
data = await _http_json(
|
|
|
"POST",
|
|
|
"https://github.com/login/oauth/access_token",
|
|
|
headers={"Accept": "application/json"},
|
|
|
data={
|
|
|
"client_id": client_id,
|
|
|
"client_secret": client_secret,
|
|
|
"code": code,
|
|
|
"redirect_uri": redirect_uri,
|
|
|
},
|
|
|
)
|
|
|
if not isinstance(data, dict):
|
|
|
raise CustomException(msg="GitHub token 响应格式错误")
|
|
|
token = data.get("access_token")
|
|
|
if not token:
|
|
|
raise CustomException(msg=data.get("error_description") or "GitHub 换取令牌失败")
|
|
|
return str(token)
|
|
|
|
|
|
|
|
|
async def exchange_gitee_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> str:
|
|
|
qs = urlencode(
|
|
|
{
|
|
|
"grant_type": "authorization_code",
|
|
|
"code": code,
|
|
|
"client_id": client_id,
|
|
|
"client_secret": client_secret,
|
|
|
"redirect_uri": redirect_uri,
|
|
|
}
|
|
|
)
|
|
|
data = await _http_json("GET", f"https://gitee.com/oauth/token?{qs}")
|
|
|
if not isinstance(data, dict):
|
|
|
raise CustomException(msg="Gitee token 响应格式错误")
|
|
|
token = data.get("access_token")
|
|
|
if not token:
|
|
|
raise CustomException(msg=data.get("error_description") or "Gitee 换取令牌失败")
|
|
|
return str(token)
|
|
|
|
|
|
|
|
|
async def exchange_wechat_token(app_id: str, secret: str, code: str) -> tuple[str, str]:
|
|
|
qs = urlencode(
|
|
|
{
|
|
|
"appid": app_id,
|
|
|
"secret": secret,
|
|
|
"code": code,
|
|
|
"grant_type": "authorization_code",
|
|
|
}
|
|
|
)
|
|
|
data = await _http_json("GET", f"https://api.weixin.qq.com/sns/oauth2/access_token?{qs}")
|
|
|
if not isinstance(data, dict):
|
|
|
raise CustomException(msg="微信 token 响应格式错误")
|
|
|
token = data.get("access_token")
|
|
|
openid = data.get("openid")
|
|
|
if not token or not openid:
|
|
|
raise CustomException(msg=data.get("errmsg") or "微信换取令牌失败")
|
|
|
return str(token), str(openid)
|
|
|
|
|
|
|
|
|
async def exchange_qq_token(client_id: str, client_secret: str, code: str, redirect_uri: str) -> tuple[str, str]:
|
|
|
qs = urlencode(
|
|
|
{
|
|
|
"grant_type": "authorization_code",
|
|
|
"client_id": client_id,
|
|
|
"client_secret": client_secret,
|
|
|
"code": code,
|
|
|
"redirect_uri": redirect_uri,
|
|
|
}
|
|
|
)
|
|
|
text = await _http_text("GET", f"https://graph.qq.com/oauth2.0/token?{qs}")
|
|
|
parts = dict(p.split("=", 1) for p in text.split("&") if "=" in p)
|
|
|
token = parts.get("access_token")
|
|
|
if not token:
|
|
|
raise CustomException(msg="QQ 换取 access_token 失败")
|
|
|
me = await _http_json(
|
|
|
"GET",
|
|
|
"https://graph.qq.com/oauth2.0/me",
|
|
|
params={"access_token": token, "fmt": "json"},
|
|
|
)
|
|
|
if not isinstance(me, dict):
|
|
|
raise CustomException(msg="QQ openid 响应格式错误")
|
|
|
openid = me.get("openid")
|
|
|
if not openid:
|
|
|
raise CustomException(msg="QQ 获取 openid 失败")
|
|
|
return str(token), str(openid)
|
|
|
|
|
|
|
|
|
async def fetch_github_profile(access_token: str) -> tuple[str, str, str | None]:
|
|
|
headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
|
|
|
user = await _http_json("GET", "https://api.github.com/user", headers=headers)
|
|
|
if not isinstance(user, dict):
|
|
|
raise CustomException(msg="GitHub 用户信息格式错误")
|
|
|
login = str(user.get("login") or "")
|
|
|
name = str(user.get("name") or login or "github")
|
|
|
email = user.get("email")
|
|
|
if not email:
|
|
|
emails = await _http_json("GET", "https://api.github.com/user/emails", headers=headers)
|
|
|
if isinstance(emails, list):
|
|
|
primary = next((e for e in emails if isinstance(e, dict) and e.get("primary")), None)
|
|
|
if primary:
|
|
|
email = primary.get("email")
|
|
|
return login, name, email
|
|
|
|
|
|
|
|
|
async def fetch_gitee_profile(access_token: str) -> tuple[str, str, str | None]:
|
|
|
user = await _http_json(
|
|
|
"GET",
|
|
|
"https://gitee.com/api/v5/user",
|
|
|
params={"access_token": access_token},
|
|
|
)
|
|
|
if not isinstance(user, dict):
|
|
|
raise CustomException(msg="Gitee 用户信息格式错误")
|
|
|
login = str(user.get("login") or "")
|
|
|
name = str(user.get("name") or login)
|
|
|
email = user.get("email")
|
|
|
return login, name, email
|
|
|
|
|
|
|
|
|
async def fetch_wechat_profile(access_token: str, openid: str) -> tuple[str, str]:
|
|
|
qs = urlencode({"access_token": access_token, "openid": openid, "lang": "zh_CN"})
|
|
|
user = await _http_json("GET", f"https://api.weixin.qq.com/sns/userinfo?{qs}")
|
|
|
if not isinstance(user, dict):
|
|
|
raise CustomException(msg="微信用户信息格式错误")
|
|
|
nickname = str(user.get("nickname") or "wechat")
|
|
|
unionid = user.get("unionid")
|
|
|
oid = unionid or openid
|
|
|
return str(oid), nickname
|
|
|
|
|
|
|
|
|
async def fetch_qq_profile(access_token: str, app_id: str, openid: str) -> tuple[str, str]:
|
|
|
qs = urlencode(
|
|
|
{
|
|
|
"access_token": access_token,
|
|
|
"oauth_consumer_key": app_id,
|
|
|
"openid": openid,
|
|
|
}
|
|
|
)
|
|
|
user = await _http_json("GET", f"https://graph.qq.com/user/get_user_info?{qs}")
|
|
|
if not isinstance(user, dict):
|
|
|
raise CustomException(msg="QQ 用户信息格式错误")
|
|
|
if user.get("ret") not in (0, "0", None):
|
|
|
raise CustomException(msg=user.get("msg") or "QQ 用户信息失败")
|
|
|
nickname = str(user.get("nickname") or "qq")
|
|
|
return openid, nickname
|
|
|
|
|
|
|
|
|
def _username_for_oauth(provider: OAuthProvider, unique_id: str) -> str:
|
|
|
"""生成符合注册规则的登录名:oauth_{provider}_{id}。"""
|
|
|
raw = f"oauth_{provider}_{unique_id}"
|
|
|
raw = "".join(c if c.isalnum() or c in "_-." else "_" for c in raw)[:32]
|
|
|
if len(raw) < 3:
|
|
|
raw = (raw + "usr")[:32]
|
|
|
if not raw[0].isalpha():
|
|
|
raw = "o" + raw[:31]
|
|
|
return raw
|
|
|
|
|
|
|
|
|
async def ensure_oauth_user(
|
|
|
*,
|
|
|
db: AsyncSession,
|
|
|
provider: OAuthProvider,
|
|
|
unique_id: str,
|
|
|
display_name: str,
|
|
|
) -> UserModel:
|
|
|
auth = AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False)
|
|
|
username = _username_for_oauth(provider, unique_id)
|
|
|
existing = await UserCRUD(auth).get(username=username)
|
|
|
if existing:
|
|
|
return existing
|
|
|
|
|
|
reg = UserRegisterSchema(
|
|
|
username=username,
|
|
|
password=secrets.token_urlsafe(24),
|
|
|
name=(display_name or username)[:32],
|
|
|
role_ids=list(settings.OAUTH_DEFAULT_ROLE_IDS),
|
|
|
)
|
|
|
try:
|
|
|
await UserService(auth).register(data=reg)
|
|
|
except Exception:
|
|
|
# 并发创建可能触发唯一约束冲突,回退到再次查询
|
|
|
existing = await UserCRUD(auth).get(username=username)
|
|
|
if existing:
|
|
|
return existing
|
|
|
raise CustomException(msg="OAuth 注册失败")
|
|
|
user = await UserCRUD(auth).get(username=username)
|
|
|
if not user:
|
|
|
raise CustomException(msg="OAuth 注册失败")
|
|
|
logger.info(f"OAuth 自动注册用户: {username} ({provider})")
|
|
|
return user
|
|
|
|
|
|
|
|
|
async def complete_oauth_login(
|
|
|
*,
|
|
|
request: Request,
|
|
|
redis: Redis,
|
|
|
db: AsyncSession,
|
|
|
provider: OAuthProvider,
|
|
|
code: str,
|
|
|
state: str,
|
|
|
) -> tuple[JWTOutSchema, str]:
|
|
|
rc = RedisCURD(redis)
|
|
|
raw = await rc.get(f"{STATE_PREFIX}{state}")
|
|
|
if not raw:
|
|
|
raise CustomException(msg="登录状态已失效,请重试")
|
|
|
if isinstance(raw, bytes):
|
|
|
raw = raw.decode("utf-8")
|
|
|
payload = json.loads(raw)
|
|
|
if payload.get("provider") != provider:
|
|
|
raise CustomException(msg="OAuth 状态不匹配")
|
|
|
|
|
|
frontend = str(payload.get("frontend_redirect") or "").strip()
|
|
|
if not frontend:
|
|
|
raise CustomException(msg="缺少前端回调地址")
|
|
|
|
|
|
callback_url = _callback_url(request, provider)
|
|
|
cid, csec = _require_credentials(provider)
|
|
|
|
|
|
if provider == "github":
|
|
|
access = await exchange_github_token(cid, csec, code, callback_url)
|
|
|
login_k, name, _email = await fetch_github_profile(access)
|
|
|
uid = login_k
|
|
|
elif provider == "gitee":
|
|
|
access = await exchange_gitee_token(cid, csec, code, callback_url)
|
|
|
login_k, name, _email = await fetch_gitee_profile(access)
|
|
|
uid = login_k
|
|
|
elif provider == "wechat":
|
|
|
access, openid = await exchange_wechat_token(cid, csec, code)
|
|
|
uid, name = await fetch_wechat_profile(access, openid)
|
|
|
elif provider == "qq":
|
|
|
access, openid = await exchange_qq_token(cid, csec, code, callback_url)
|
|
|
uid, name = await fetch_qq_profile(access, cid, openid)
|
|
|
else:
|
|
|
raise CustomException(msg="不支持的 OAuth 渠道")
|
|
|
|
|
|
user = await ensure_oauth_user(db=db, provider=provider, unique_id=uid, display_name=name)
|
|
|
if user.status == 1:
|
|
|
raise CustomException(msg="用户已被停用")
|
|
|
|
|
|
user = await UserCRUD(AuthSchema(db=db, user=None, tenant_id=1, check_data_scope=False)).update_last_login_crud(id=user.id)
|
|
|
if not user:
|
|
|
raise CustomException(msg="用户不存在")
|
|
|
|
|
|
login_type = f"oauth_{provider}"
|
|
|
token = await LoginService.create_token(request=request, redis=redis, user=user, login_type=login_type)
|
|
|
await rc.delete(f"{STATE_PREFIX}{state}")
|
|
|
return token, frontend
|
|
|
|
|
|
|
|
|
async def save_oauth_state(
|
|
|
*,
|
|
|
redis: Redis,
|
|
|
state: str,
|
|
|
provider: OAuthProvider,
|
|
|
frontend_redirect: str,
|
|
|
) -> None:
|
|
|
rc = RedisCURD(redis)
|
|
|
ok = await rc.set(
|
|
|
f"{STATE_PREFIX}{state}",
|
|
|
json.dumps({"provider": provider, "frontend_redirect": frontend_redirect}),
|
|
|
expire=STATE_TTL_SECONDS,
|
|
|
)
|
|
|
if not ok:
|
|
|
raise CustomException(msg="缓存 OAuth 状态失败")
|
|
|
|
|
|
|
|
|
def oauth_service_frontend_redirect_from_token(frontend_base: str, token: JWTOutSchema) -> str:
|
|
|
return _frontend_success_redirect(
|
|
|
frontend_base,
|
|
|
token.access_token,
|
|
|
token.refresh_token,
|
|
|
token.token_type,
|
|
|
)
|
|
|
|
|
|
|
|
|
def oauth_service_error_redirect(frontend_base: str, message: str) -> str:
|
|
|
return _frontend_error_redirect(frontend_base, message)
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
"OAuthProvider",
|
|
|
"STATE_PREFIX",
|
|
|
"build_authorize_url",
|
|
|
"complete_oauth_login",
|
|
|
"save_oauth_state",
|
|
|
"_callback_url",
|
|
|
"oauth_service_frontend_redirect_from_token",
|
|
|
"oauth_service_error_redirect",
|
|
|
]
|