mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Add OAuth2 tenant support
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[plugin]
|
||||
summary = "OAuth 2.0"
|
||||
version = "0.0.11"
|
||||
version = "0.1.0"
|
||||
description = "支持 GitHub、Google 等社交平台登录"
|
||||
author = "wu-clan"
|
||||
tags = ["auth"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user