feat(auth): 添加第三方OAuth登录支持

- 实现OAuth登录重定向和回调处理
- 更新配置文件以支持多种OAuth提供商
- 添加错误处理和状态管理功能
- 更新文档以反映新功能和配置要求
This commit is contained in:
zhangtao
2026-05-03 06:42:13 +08:00
parent e4f778877b
commit b12f7a0a11
699 changed files with 89018 additions and 31150 deletions
@@ -1,13 +1,17 @@
import json
import secrets
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Path, Query, Request
from fastapi.responses import JSONResponse, RedirectResponse
from redis.asyncio.client import Redis
from sqlalchemy.ext.asyncio import AsyncSession
from app.common.response import ErrorResponse, SuccessResponse
from app.config.setting import settings
from app.core.dependencies import db_getter, get_current_user, redis_getter
from app.core.exceptions import CustomException
from app.core.redis_crud import RedisCURD
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from app.core.security import CustomOAuth2PasswordRequestForm
@@ -20,6 +24,15 @@ from .schema import (
LogoutPayloadSchema,
RefreshTokenPayloadSchema,
)
from .oauth_service import (
STATE_PREFIX,
build_authorize_url,
complete_oauth_login,
oauth_service_error_redirect,
oauth_service_frontend_redirect_from_token,
save_oauth_state,
_callback_url,
)
from .service import AutoLoginService, CaptchaService, LoginService
AuthRouter = APIRouter(route_class=OperationLogRoute, prefix="/auth", tags=["认证授权"])
@@ -236,3 +249,100 @@ async def auto_login_controller(
)
log.info("用户免登录成功")
return SuccessResponse(data=login_token.model_dump(), msg="登录成功")
@AuthRouter.get(
"/oauth/{provider}/login",
summary="第三方OAuth跳转",
description="浏览器重定向到微信/GitHub/Gitee/QQ 授权页;redirect_uri 为授权完成后回到前端的登录页地址(如 http://localhost:5173/login)。",
)
async def oauth_login_redirect_controller(
request: Request,
redis: Annotated[Redis, Depends(redis_getter)],
provider: Annotated[str, Path(description="wechat | qq | github | gitee")],
redirect_uri: Annotated[
str | None,
Query(description="OAuth 完成后浏览器回到的前端登录页完整 URL"),
] = None,
) -> RedirectResponse:
allowed = {"wechat", "qq", "github", "gitee"}
fe = redirect_uri or settings.OAUTH_FRONTEND_FALLBACK
if provider not in allowed:
return RedirectResponse(
url=oauth_service_error_redirect(fe, "不支持的 OAuth 渠道"),
status_code=302,
)
if not redirect_uri:
return RedirectResponse(
url=oauth_service_error_redirect(fe, "缺少 redirect_uri 参数"),
status_code=302,
)
try:
state = secrets.token_urlsafe(32)
await save_oauth_state(
redis=redis,
state=state,
provider=provider,
frontend_redirect=redirect_uri,
)
cb = _callback_url(request, provider)
url = build_authorize_url(provider=provider, callback_url=cb, state=state)
return RedirectResponse(url=url, status_code=302)
except CustomException as e:
return RedirectResponse(
url=oauth_service_error_redirect(redirect_uri, e.msg),
status_code=302,
)
@AuthRouter.get(
"/oauth/{provider}/callback",
summary="第三方OAuth回调",
include_in_schema=False,
)
async def oauth_callback_controller(
request: Request,
redis: Annotated[Redis, Depends(redis_getter)],
db: Annotated[AsyncSession, Depends(db_getter)],
provider: Annotated[str, Path()],
code: Annotated[str | None, Query()] = None,
state: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
fe_fallback = settings.OAUTH_FRONTEND_FALLBACK
async def resolve_frontend() -> str:
if not state:
return fe_fallback
raw = await RedisCURD(redis).get(f"{STATE_PREFIX}{state}")
if not raw:
return fe_fallback
if isinstance(raw, bytes):
raw = raw.decode("utf-8")
try:
payload = json.loads(raw)
return str(payload.get("frontend_redirect") or fe_fallback).strip() or fe_fallback
except json.JSONDecodeError:
return fe_fallback
if provider not in {"wechat", "qq", "github", "gitee"}:
url = oauth_service_error_redirect(await resolve_frontend(), "不支持的 OAuth 渠道")
return RedirectResponse(url=url, status_code=302)
if not code or not state:
url = oauth_service_error_redirect(
await resolve_frontend(), "授权被取消或参数不完整"
)
return RedirectResponse(url=url, status_code=302)
try:
token, fe = await complete_oauth_login(
request=request,
redis=redis,
db=db,
provider=provider,
code=code,
state=state,
)
success_url = oauth_service_frontend_redirect_from_token(fe, token)
return RedirectResponse(url=success_url, status_code=302)
except CustomException as e:
fe = await resolve_frontend()
return RedirectResponse(url=oauth_service_error_redirect(fe, e.msg), status_code=302)
@@ -0,0 +1,437 @@
"""
第三方 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_* 字段。
"""
from __future__ import annotations
import json
import secrets
from typing import Any, Literal, Tuple
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.auth.schema import AuthSchema
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.exceptions import CustomException
from app.core.logger import log
from app.core.redis_crud import RedisCURD
from .schema import JWTOutSchema
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
log.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, check_data_scope=False)
username = _username_for_oauth(provider, unique_id)
existing = await UserCRUD(auth).get_by_username_crud(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),
)
await UserService.register_user_service(auth=auth, data=reg)
user = await UserCRUD(auth).get_by_username_crud(username=username)
if not user:
raise CustomException(msg="OAuth 注册失败")
log.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, 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_service(
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",
]