Add OAuth2 tenant support

This commit is contained in:
Wu Clan
2026-03-15 02:48:53 +08:00
parent e5b8a5242d
commit 46ad7a5d7f
8 changed files with 161 additions and 87 deletions
+5 -17
View File
@@ -1,18 +1,14 @@
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi_oauth20 import FastAPIOAuth20, GitHubOAuth20
from pyrate_limiter import Duration, Rate
from starlette.responses import RedirectResponse
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSessionTransaction
from backend.database.redis import redis_client
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
from backend.utils.limiter import RateLimiter
@@ -22,16 +18,8 @@ github_client = GitHubOAuth20(settings.OAUTH2_GITHUB_CLIENT_ID, settings.OAUTH2_
@router.get('', summary='获取 Github 授权链接')
async def get_github_oauth2_url() -> ResponseSchemaModel[str]:
state = str(uuid.uuid4())
await redis_client.setex(
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
settings.OAUTH2_STATE_EXPIRE_SECONDS,
json.dumps({'type': UserSocialAuthType.login.value}),
)
auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, state=state)
async def get_github_oauth2_url(db: CurrentSession, request: Request) -> ResponseSchemaModel[str]:
auth_url = await oauth2_service.get_login_auth_url(db=db, request=request, source=UserSocialType.github)
return response_base.success(data=auth_url)
+5 -17
View File
@@ -1,18 +1,14 @@
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Response
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi_oauth20 import FastAPIOAuth20, GoogleOAuth20
from pyrate_limiter import Duration, Rate
from starlette.responses import RedirectResponse
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSessionTransaction
from backend.database.redis import redis_client
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
from backend.database.db import CurrentSession, CurrentSessionTransaction
from backend.plugin.oauth2.enums import UserSocialType
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
from backend.utils.limiter import RateLimiter
@@ -22,16 +18,8 @@ google_client = GoogleOAuth20(settings.OAUTH2_GOOGLE_CLIENT_ID, settings.OAUTH2_
@router.get('', summary='获取 google 授权链接')
async def get_google_oauth2_url() -> ResponseSchemaModel[str]:
state = str(uuid.uuid4())
await redis_client.setex(
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
settings.OAUTH2_STATE_EXPIRE_SECONDS,
json.dumps({'type': UserSocialAuthType.login.value}),
)
auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, state=state)
async def get_google_oauth2_url(db: CurrentSession, request: Request) -> ResponseSchemaModel[str]:
auth_url = await oauth2_service.get_login_auth_url(db=db, request=request, source=UserSocialType.google)
return response_base.success(data=auth_url)
+5 -1
View File
@@ -17,7 +17,11 @@ async def get_user_bindings(db: CurrentSession, request: Request) -> ResponseSch
@router.get('/me/binding', summary='获取绑定授权链接', dependencies=[DependsJwtAuth])
async def get_binding_auth_url(request: Request, source: UserSocialType) -> ResponseSchemaModel[str]:
binding_url = await user_social_service.get_binding_auth_url(user_id=request.user.id, source=source)
binding_url = await user_social_service.get_binding_auth_url(
user_id=request.user.id,
tenant_id=request.user.tenant_id,
source=source,
)
return response_base.success(data=binding_url)
+22 -3
View File
@@ -1,8 +1,10 @@
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model import User
from backend.plugin.oauth2.model import UserSocial
from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
@@ -21,16 +23,33 @@ class CRUDUserSocial(CRUDPlus[UserSocial]):
"""
return await self.select_model_by_column(db, user_id=user_id, source=source)
async def get_by_sid(self, db: AsyncSession, sid: str, source: str) -> UserSocial | None:
async def get_by_sid(
self,
db: AsyncSession,
tenant_id: int,
sid: str,
source: str,
) -> UserSocial | None:
"""
通过 sid 获取社交用户
通过 sid 获取当前租户内的社交用户
:param db: 数据库会话
:param tenant_id: 租户 ID
:param sid: 社交账号唯一编码
:param source: 社交账号类型
:return:
"""
return await self.select_model_by_column(db, sid=sid, source=source)
stmt = (
select(self.model)
.join(User, User.id == self.model.user_id)
.where(
self.model.sid == sid,
self.model.source == source,
User.tenant_id == tenant_id,
)
)
result = await db.execute(stmt)
return result.scalars().first()
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserSocial]:
"""
+1 -1
View File
@@ -1,6 +1,6 @@
[plugin]
summary = "OAuth 2.0"
version = "0.0.11"
version = "0.1.0"
description = "支持 GitHub、Google 等社交平台登录"
author = "wu-clan"
tags = ["auth"]
+84 -26
View File
@@ -1,9 +1,11 @@
import json
import uuid
from typing import Any
from urllib.parse import urlparse
from fast_captcha import text_captcha
from fastapi import BackgroundTasks, Response
from fastapi import BackgroundTasks, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user import user_dao
@@ -21,18 +23,62 @@ from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
from backend.plugin.oauth2.service.user_social_service import user_social_service
from backend.plugin.oauth2.utils import get_oauth2_authorization_url
from backend.utils.timezone import timezone
class OAuth2Service:
"""OAuth2 认证服务类"""
async def get_login_auth_url(self, *, db: AsyncSession, request: Request, source: UserSocialType) -> str:
"""
获取 OAuth2 登录授权链接
:param db: 数据库会话
:param request: FastAPI 请求对象
:param source: 社交平台
:return:
"""
tenant_id = settings.TENANT_DEFAULT_ID
if settings.TENANT_ENABLED:
try:
from backend.plugin.tenant.service.tenant_service import tenant_service
except ImportError:
raise errors.ServerError(msg='租户插件方法导入失败,请联系系统管理员')
tenant_domain = request.headers.get('Origin') or request.headers.get('Referer')
if tenant_domain:
tenant_domain = urlparse(tenant_domain).hostname
else:
tenant_domain = (
request.headers.get('X-Forwarded-Host')
or request.headers.get('X-Original-Host')
or request.url.hostname
)
if tenant_domain:
tenant_domain = tenant_domain.strip().split(',')[0].strip().lower()
tenant = await tenant_service.get_by_domain(db=db, domain=tenant_domain)
if tenant:
tenant_id = tenant.id
state = str(uuid.uuid4())
await redis_client.setex(
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
settings.OAUTH2_STATE_EXPIRE_SECONDS,
json.dumps({'type': UserSocialAuthType.login.value, 'tenant_id': tenant_id}),
)
return await get_oauth2_authorization_url(source=source, state=state)
@staticmethod
async def login(
*,
db: AsyncSession,
response: Response,
background_tasks: BackgroundTasks,
tenant_id: int,
sid: str,
source: UserSocialType,
username: str | None = None,
@@ -46,6 +92,7 @@ class OAuth2Service:
:param db: 数据库会话
:param response: FastAPI 响应对象
:param background_tasks: FastAPI 后台任务
:param tenant_id: 租户 ID
:param sid: 社交账号唯一编码
:param source: 社交平台
:param username: 用户名
@@ -54,7 +101,7 @@ class OAuth2Service:
:param avatar: 头像地址
:return:
"""
user_social = await user_social_dao.get_by_sid(db, sid, source.value)
user_social = await user_social_dao.get_by_sid(db, tenant_id, sid, source.value)
if user_social:
sys_user = await user_dao.get(db, user_social.user_id)
# 更新用户头像
@@ -114,6 +161,7 @@ class OAuth2Service:
login_time=timezone.now(),
status=LoginLogStatusType.success.value,
msg=t('success.login.oauth2_success'),
tenant_id=tenant_id,
)
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
response.set_cookie(
@@ -181,35 +229,45 @@ class OAuth2Service:
state_info = json.loads(state_data)
await redis_client.delete(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}')
tenant_id = int(state_info.get('tenant_id', settings.TENANT_DEFAULT_ID))
current_tenant_id = ctx.tenant_id
ctx.tenant_id = tenant_id
# 绑定流程
if state_info.get('type') == UserSocialAuthType.binding.value:
user_id = state_info.get('user_id')
if not user_id:
raise errors.ForbiddenError(msg='非法操作,OAuth2 状态信息无效')
await user_social_service.binding_with_oauth2(
try:
await jwt.check_tenant_status(db, tenant_id)
# 绑定流程
if state_info.get('type') == UserSocialAuthType.binding.value:
user_id = state_info.get('user_id')
if not user_id:
raise errors.ForbiddenError(msg='非法操作,OAuth2 状态信息无效')
await user_social_service.binding_with_oauth2(
db=db,
user_id=user_id,
sid=str(sid),
source=social,
tenant_id=tenant_id,
)
return None
# 登录流程
if state_info.get('type') != UserSocialAuthType.login.value:
raise errors.ForbiddenError(msg='OAuth2 状态信息无效')
return await self.login(
db=db,
user_id=user_id,
response=response,
background_tasks=background_tasks,
tenant_id=tenant_id,
sid=str(sid),
source=social,
username=username,
nickname=nickname,
email=email,
avatar=avatar,
)
return None
# 登录流程
if state_info.get('type') != UserSocialAuthType.login.value:
raise errors.ForbiddenError(msg='OAuth2 状态信息无效')
return await self.login(
db=db,
response=response,
background_tasks=background_tasks,
sid=str(sid),
source=social,
username=username,
nickname=nickname,
email=email,
avatar=avatar,
)
finally:
ctx.tenant_id = current_tenant_id
oauth2_service: OAuth2Service = OAuth2Service()
@@ -9,6 +9,7 @@ from backend.database.redis import redis_client
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam
from backend.plugin.oauth2.utils import get_oauth2_authorization_url
class UserSocialService:
@@ -29,6 +30,7 @@ class UserSocialService:
*,
db: AsyncSession,
user_id: int,
tenant_id: int,
sid: str,
source: UserSocialType,
) -> None:
@@ -37,6 +39,7 @@ class UserSocialService:
:param db: 数据库会话
:param user_id: 用户 ID
:param tenant_id: 租户 ID
:param sid: 社交账号唯一编码
:param source: 绑定源
:return:
@@ -44,7 +47,7 @@ class UserSocialService:
if await user_social_dao.check_binding(db, user_id, source.value):
raise errors.RequestError(msg=f'用户已绑定 {source.value} 账号')
if await user_social_dao.get_by_sid(db, sid, source.value):
if await user_social_dao.get_by_sid(db, tenant_id, sid, source.value):
raise errors.RequestError(msg=f'{source.value} 账号已被其他用户绑定')
new_user_social = CreateUserSocialParam(sid=sid, source=source.value, user_id=user_id)
@@ -66,34 +69,16 @@ class UserSocialService:
return await user_social_dao.delete(db, user_id, source.value)
@staticmethod
async def get_binding_auth_url(*, user_id: int, source: UserSocialType) -> str:
async def get_binding_auth_url(*, user_id: int, tenant_id: int, source: UserSocialType) -> str:
state = str(uuid.uuid4())
await redis_client.setex(
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
settings.OAUTH2_STATE_EXPIRE_SECONDS,
json.dumps({'type': UserSocialAuthType.binding.value, 'user_id': user_id}),
json.dumps({'type': UserSocialAuthType.binding.value, 'user_id': user_id, 'tenant_id': tenant_id}),
)
match source:
case UserSocialType.github:
from backend.plugin.oauth2.api.v1.github import github_client
auth_url = await github_client.get_authorization_url(
redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI,
state=state,
)
case UserSocialType.google:
from backend.plugin.oauth2.api.v1.google import google_client
auth_url = await google_client.get_authorization_url(
redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI,
state=state,
)
case _:
raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定')
return auth_url
return await get_oauth2_authorization_url(source=source, state=state)
user_social_service: UserSocialService = UserSocialService()
+32
View File
@@ -0,0 +1,32 @@
from backend.common.exception import errors
from backend.core.conf import settings
from backend.plugin.oauth2.enums import UserSocialType
async def get_oauth2_authorization_url(*, source: UserSocialType, state: str) -> str:
"""
获取 OAuth2 授权链接
:param source: 社交平台
:param state: OAuth2 状态值
:return:
"""
match source:
case UserSocialType.github:
from backend.plugin.oauth2.api.v1.github import github_client
auth_url = await github_client.get_authorization_url(
redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI,
state=state,
)
case UserSocialType.google:
from backend.plugin.oauth2.api.v1.google import google_client
auth_url = await google_client.get_authorization_url(
redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI,
state=state,
)
case _:
raise errors.ForbiddenError(msg=f'暂不支持 {source} OAuth2 授权')
return auth_url