diff --git a/backend/core/conf.py b/backend/core/conf.py index fecd35c1..b7ac0da8 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -231,10 +231,13 @@ class Settings(BaseSettings): OAUTH2_LINUX_DO_CLIENT_SECRET: str # 基础配置 + OAUTH2_STATE_REDIS_PREFIX: str = 'fba:oauth2:state' + OAUTH2_STATE_EXPIRE_SECONDS: int = 60 * 3 # 3 分钟 OAUTH2_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback' OAUTH2_GOOGLE_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/google/callback' OAUTH2_LINUX_DO_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/linux-do/callback' - OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback' + OAUTH2_FRONTEND_LOGIN_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback' + OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str = 'http://localhost:5173/profile' ################################################## # [ Plugin ] email diff --git a/backend/plugin/oauth2/api/router.py b/backend/plugin/oauth2/api/router.py index 53f37c8c..a827968c 100644 --- a/backend/plugin/oauth2/api/router.py +++ b/backend/plugin/oauth2/api/router.py @@ -4,9 +4,11 @@ from backend.plugin.oauth2.api.v1.github import router as github_router from backend.plugin.oauth2.api.v1.google import router as google_router from backend.plugin.oauth2.api.v1.linux_do import router as linux_do_router +from backend.plugin.oauth2.api.v1.user_social import router as user_social_router v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/oauth2') +v1.include_router(user_social_router, tags=['OAuth2']) v1.include_router(github_router, prefix='/github', tags=['Github OAuth2']) v1.include_router(google_router, prefix='/google', tags=['Google OAuth2']) v1.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2']) diff --git a/backend/plugin/oauth2/api/v1/github.py b/backend/plugin/oauth2/api/v1/github.py index 20cd0e61..4bc35fb7 100644 --- a/backend/plugin/oauth2/api/v1/github.py +++ b/backend/plugin/oauth2/api/v1/github.py @@ -1,3 +1,6 @@ +import json +import uuid + from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, Response @@ -8,7 +11,8 @@ from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.core.conf import settings from backend.database.db import CurrentSessionTransaction -from backend.plugin.oauth2.enums import UserSocialType +from backend.database.redis import redis_client +from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service router = APIRouter() @@ -18,7 +22,15 @@ @router.get('', summary='获取 Github 授权链接') async def get_github_oauth2_url() -> ResponseSchemaModel[str]: - auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI) + 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) return response_base.success(data=auth_url) @@ -37,16 +49,23 @@ async def github_oauth2_callback( # noqa: ANN201 Depends(FastAPIOAuth20(github_client, redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)), ], ): - token_data, _state = oauth2 + token_data, state = oauth2 access_token = token_data['access_token'] user = await github_client.get_userinfo(access_token) - data = await oauth2_service.create_with_login( + data = await oauth2_service.login_or_binding( db=db, response=response, background_tasks=background_tasks, user=user, social=UserSocialType.github, + state=state, ) + + # 绑定流程 + if data is None: + return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI) + + # 登录流程 return RedirectResponse( - url=f'{settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}', + url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}', ) diff --git a/backend/plugin/oauth2/api/v1/google.py b/backend/plugin/oauth2/api/v1/google.py index 0456b921..77eb7c6d 100644 --- a/backend/plugin/oauth2/api/v1/google.py +++ b/backend/plugin/oauth2/api/v1/google.py @@ -1,3 +1,6 @@ +import json +import uuid + from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, Response @@ -8,7 +11,8 @@ from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.core.conf import settings from backend.database.db import CurrentSessionTransaction -from backend.plugin.oauth2.enums import UserSocialType +from backend.database.redis import redis_client +from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service router = APIRouter() @@ -18,7 +22,15 @@ @router.get('', summary='获取 google 授权链接') async def get_google_oauth2_url() -> ResponseSchemaModel[str]: - auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI) + 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) return response_base.success(data=auth_url) @@ -37,16 +49,23 @@ async def google_oauth2_callback( # noqa: ANN201 Depends(FastAPIOAuth20(google_client, redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI)), ], ): - token_data, _state = oauth2 + token_data, state = oauth2 access_token = token_data['access_token'] user = await google_client.get_userinfo(access_token) - data = await oauth2_service.create_with_login( + data = await oauth2_service.login_or_binding( db=db, response=response, background_tasks=background_tasks, user=user, social=UserSocialType.google, + state=state, ) + + # 绑定流程 + if data is None: + return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI) + + # 登录流程 return RedirectResponse( - url=f'{settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}', + url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}', ) diff --git a/backend/plugin/oauth2/api/v1/linux_do.py b/backend/plugin/oauth2/api/v1/linux_do.py index 97f2f87b..73420fbc 100644 --- a/backend/plugin/oauth2/api/v1/linux_do.py +++ b/backend/plugin/oauth2/api/v1/linux_do.py @@ -1,3 +1,6 @@ +import json +import uuid + from typing import Annotated from fastapi import APIRouter, BackgroundTasks, Depends, Response @@ -8,7 +11,8 @@ from backend.common.response.response_schema import ResponseSchemaModel, response_base from backend.core.conf import settings from backend.database.db import CurrentSessionTransaction -from backend.plugin.oauth2.enums import UserSocialType +from backend.database.redis import redis_client +from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service router = APIRouter() @@ -18,7 +22,17 @@ @router.get('', summary='获取 LinuxDo 授权链接') async def get_linux_do_oauth2_url() -> ResponseSchemaModel[str]: - auth_url = await linux_do_client.get_authorization_url(redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI) + 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 linux_do_client.get_authorization_url( + redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI, state=state + ) return response_base.success(data=auth_url) @@ -37,16 +51,23 @@ async def linux_do_oauth2_callback( # noqa: ANN201 Depends(FastAPIOAuth20(linux_do_client, redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)), ], ): - token_data, _state = oauth2 + token_data, state = oauth2 access_token = token_data['access_token'] user = await linux_do_client.get_userinfo(access_token) - data = await oauth2_service.create_with_login( + data = await oauth2_service.login_or_binding( db=db, response=response, background_tasks=background_tasks, user=user, social=UserSocialType.linux_do, + state=state, ) + + # 绑定流程 + if data is None: + return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI) + + # 登录流程 return RedirectResponse( - url=f'{settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}', + url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}', ) diff --git a/backend/plugin/oauth2/api/v1/user_social.py b/backend/plugin/oauth2/api/v1/user_social.py index 0f81628d..7041e5d3 100644 --- a/backend/plugin/oauth2/api/v1/user_social.py +++ b/backend/plugin/oauth2/api/v1/user_social.py @@ -1,15 +1,27 @@ from fastapi import APIRouter, Request -from backend.common.response.response_schema import ResponseModel, response_base +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base from backend.common.security.jwt import DependsJwtAuth -from backend.database.db import CurrentSessionTransaction +from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.plugin.oauth2.enums import UserSocialType -from backend.plugin.oauth2.service.user_social import user_social_service +from backend.plugin.oauth2.service.user_social_service import user_social_service router = APIRouter() -@router.delete('/me', summary='解绑用户社交账号', dependencies=[DependsJwtAuth]) +@router.get('/me/bindings', summary='获取用户已绑定的社交账号', dependencies=[DependsJwtAuth]) +async def get_user_bindings(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[str]]: + bindings = await user_social_service.get_bindings(db=db, user_id=request.user.id) + return response_base.success(data=bindings) + + +@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) + return response_base.success(data=binding_url) + + +@router.delete('/me/unbinding', summary='解绑用户社交账号', dependencies=[DependsJwtAuth]) async def unbinding_user(db: CurrentSessionTransaction, request: Request, source: UserSocialType) -> ResponseModel: await user_social_service.unbinding(db=db, user_id=request.user.id, source=source) return response_base.success() diff --git a/backend/plugin/oauth2/crud/crud_user_social.py b/backend/plugin/oauth2/crud/crud_user_social.py index ab7c6f7c..10bca224 100644 --- a/backend/plugin/oauth2/crud/crud_user_social.py +++ b/backend/plugin/oauth2/crud/crud_user_social.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy_crud_plus import CRUDPlus @@ -24,12 +26,22 @@ async def get_by_sid(self, db: AsyncSession, sid: str, source: str) -> UserSocia 通过 sid 获取社交用户 :param db: 数据库会话 - :param sid: 第三方用户唯一编码 + :param sid: 社交账号唯一编码 :param source: 社交账号类型 :return: """ return await self.select_model_by_column(db, sid=sid, source=source) + async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserSocial]: + """ + 通过用户 ID 获取所有社交账号绑定 + + :param db: 数据库会话 + :param user_id: 用户 ID + :return: + """ + return await self.select_models(db, user_id=user_id) + async def create(self, db: AsyncSession, obj: CreateUserSocialParam) -> None: """ 创建用户社交账号绑定 diff --git a/backend/plugin/oauth2/enums.py b/backend/plugin/oauth2/enums.py index abd68bd6..5c69af55 100644 --- a/backend/plugin/oauth2/enums.py +++ b/backend/plugin/oauth2/enums.py @@ -4,6 +4,13 @@ class UserSocialType(StrEnum): """用户社交类型""" - github = 'GitHub' + github = 'Github' google = 'Google' linux_do = 'LinuxDo' + + +class UserSocialAuthType(StrEnum): + """用户社交授权类型""" + + login = 'login' + binding = 'binding' diff --git a/backend/plugin/oauth2/plugin.toml b/backend/plugin/oauth2/plugin.toml index c395a553..92a22f20 100644 --- a/backend/plugin/oauth2/plugin.toml +++ b/backend/plugin/oauth2/plugin.toml @@ -1,6 +1,6 @@ [plugin] summary = 'OAuth 2.0' -version = '0.0.9' +version = '0.0.10' description = '通过 OAuth 2.0 的方式登录系统' author = 'wu-clan' diff --git a/backend/plugin/oauth2/schema/user_social.py b/backend/plugin/oauth2/schema/user_social.py index 3e2ece91..9585a38d 100644 --- a/backend/plugin/oauth2/schema/user_social.py +++ b/backend/plugin/oauth2/schema/user_social.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import ConfigDict, Field from backend.common.schema import SchemaBase from backend.plugin.oauth2.enums import UserSocialType @@ -19,3 +19,11 @@ class CreateUserSocialParam(UserSocialSchemaBase): class UpdateUserSocialParam(SchemaBase): """更新用户社交参数""" + + +class GetUserSocialDetail(CreateUserSocialParam): + """获取用户社交详情""" + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(description='用户社交 ID') diff --git a/backend/plugin/oauth2/service/oauth2_service.py b/backend/plugin/oauth2/service/oauth2_service.py index cfe7f8bd..c5f85686 100644 --- a/backend/plugin/oauth2/service/oauth2_service.py +++ b/backend/plugin/oauth2/service/oauth2_service.py @@ -1,3 +1,5 @@ +import json + from typing import Any from fast_captcha import text_captcha @@ -10,13 +12,15 @@ from backend.app.admin.service.login_log_service import login_log_service from backend.common.context import ctx from backend.common.enums import LoginLogStatusType +from backend.common.exception import errors from backend.common.i18n import t from backend.common.security import jwt from backend.core.conf import settings 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 UserSocialType +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.utils.timezone import timezone @@ -24,47 +28,33 @@ class OAuth2Service: """OAuth2 认证服务类""" @staticmethod - async def create_with_login( + async def login( *, db: AsyncSession, response: Response, background_tasks: BackgroundTasks, - user: dict[str, Any], - social: UserSocialType, - ) -> GetLoginToken | None: + sid: str, + source: UserSocialType, + username: str | None = None, + nickname: str | None = None, + email: str | None = None, + avatar: str | None = None, + ) -> GetLoginToken: """ - 创建 OAuth2 用户并登录 + OAuth2 用户登录 :param db: 数据库会话 :param response: FastAPI 响应对象 :param background_tasks: FastAPI 后台任务 - :param user: OAuth2 用户信息 - :param social: 社交平台类型 + :param sid: 社交账号唯一编码 + :param source: 社交平台 + :param username: 用户名 + :param nickname: 昵称 + :param email: 邮箱 + :param avatar: 头像地址 :return: """ - - sid = user.get('uuid') - username = user.get('username') - nickname = user.get('nickname') - email = user.get('email') - avatar = user.get('avatar_url') - - if social == UserSocialType.github: - sid = user.get('id') - username = user.get('login') - nickname = user.get('name') - - if social == UserSocialType.google: - sid = user.get('id') - username = user.get('name') - nickname = user.get('given_name') - avatar = user.get('picture') - - if social == UserSocialType.linux_do: - sid = user.get('id') - nickname = user.get('name') - - user_social = await user_social_dao.get_by_sid(db, str(sid), str(social.value)) + user_social = await user_social_dao.get_by_sid(db, sid, source.value) if user_social: sys_user = await user_dao.get(db, user_social.user_id) # 更新用户头像 @@ -74,7 +64,7 @@ async def create_with_login( sys_user = None # 检测系统用户是否已存在 if email: - sys_user = await user_dao.check_email(db, email) # 通过邮箱验证绑定保证邮箱真实性 + sys_user = await user_dao.check_email(db, email) # 创建系统用户 if not sys_user: @@ -92,7 +82,7 @@ async def create_with_login( sys_user = await user_dao.get_by_username(db, username) # 绑定社交账号 - new_user_social = CreateUserSocialParam(sid=str(sid), source=social.value, user_id=sys_user.id) + new_user_social = CreateUserSocialParam(sid=sid, source=source.value, user_id=sys_user.id) await user_social_dao.create(db, new_user_social) # 创建 token @@ -140,5 +130,88 @@ async def create_with_login( ) return data + async def login_or_binding( + self, + *, + db: AsyncSession, + response: Response, + background_tasks: BackgroundTasks, + user: dict[str, Any], + social: UserSocialType, + state: str | None = None, + ) -> GetLoginToken | None: + """ + OAuth2 登录或绑定 + + :param db: 数据库会话 + :param response: FastAPI 响应对象 + :param background_tasks: FastAPI 后台任务 + :param user: OAuth2 用户信息 + :param social: 社交平台类型 + :param state: OAuth2 state 参数 + :return: + """ + + sid = user.get('uuid') + username = user.get('username') + nickname = user.get('nickname') + email = user.get('email') + avatar = user.get('avatar_url') + + match social: + case UserSocialType.github: + sid = user.get('id') + username = user.get('login') + nickname = user.get('name') + case UserSocialType.google: + sid = user.get('id') + username = user.get('name') + nickname = user.get('given_name') + avatar = user.get('picture') + case UserSocialType.linux_do: + sid = user.get('id') + nickname = user.get('name') + case _: + raise errors.ForbiddenError(msg=f'暂不支持 {social} OAuth2 登录') + + if not state: + raise errors.ForbiddenError(msg='OAuth2 状态信息缺失') + + state_data = await redis_client.get(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') + if not state_data: + raise errors.ForbiddenError(msg='OAuth2 状态信息无效或缺失') + + state_info = json.loads(state_data) + await redis_client.delete(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') + + # 绑定流程 + 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, + ) + 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, + ) + oauth2_service: OAuth2Service = OAuth2Service() diff --git a/backend/plugin/oauth2/service/user_social.py b/backend/plugin/oauth2/service/user_social.py deleted file mode 100644 index 4230de88..00000000 --- a/backend/plugin/oauth2/service/user_social.py +++ /dev/null @@ -1,25 +0,0 @@ -from sqlalchemy.ext.asyncio import AsyncSession - -from backend.common.exception import errors -from backend.plugin.oauth2.crud.crud_user_social import user_social_dao -from backend.plugin.oauth2.enums import UserSocialType - - -class UserSocialService: - @staticmethod - async def unbinding(*, db: AsyncSession, user_id: int, source: UserSocialType) -> int: - """ - 解绑用户社交账号 - - :param db: 数据库会话 - :param user_id: 用户 ID - :param source: 解绑源 - :return: - """ - bind = user_social_dao.check_binding(db, user_id, source.value) - if not bind: - raise errors.NotFoundError(msg=f'用户未绑定 {source.value} 账号') - return await user_social_dao.delete(db, user_id, source.value) - - -user_social_service: UserSocialService = UserSocialService() diff --git a/backend/plugin/oauth2/service/user_social_service.py b/backend/plugin/oauth2/service/user_social_service.py new file mode 100644 index 00000000..0688d6ff --- /dev/null +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -0,0 +1,106 @@ +import json +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.common.exception import errors +from backend.core.conf import settings +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 + + +class UserSocialService: + @staticmethod + async def get_bindings(*, db: AsyncSession, user_id: int) -> list[str]: + """ + 获取用户已绑定的社交账号 + + :param db: 数据库会话 + :param user_id: 用户 ID + :return: 绑定列表,每个元素包含 sid、source 等信息 + """ + bindings = await user_social_dao.get_by_user_id(db, user_id) + return [binding.source for binding in bindings] + + @staticmethod + async def binding_with_oauth2( + *, + db: AsyncSession, + user_id: int, + sid: str, + source: UserSocialType, + ) -> None: + """ + 通过 OAuth2 流程绑定用户社交账号 + + :param db: 数据库会话 + :param user_id: 用户 ID + :param sid: 社交账号唯一编码 + :param source: 绑定源 + :return: + """ + 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): + raise errors.RequestError(msg=f'该 {source.value} 账号已被其他用户绑定') + + new_user_social = CreateUserSocialParam(sid=sid, source=source.value, user_id=user_id) + await user_social_dao.create(db, new_user_social) + + @staticmethod + async def unbinding(*, db: AsyncSession, user_id: int, source: UserSocialType) -> int: + """ + 解绑用户社交账号 + + :param db: 数据库会话 + :param user_id: 用户 ID + :param source: 解绑源 + :return: + """ + bind = await user_social_dao.check_binding(db, user_id, source.value) + if not bind: + raise errors.NotFoundError(msg=f'用户未绑定 {source.value} 账号') + return await user_social_dao.delete(db, user_id, source.value) + + @staticmethod + async def get_binding_auth_url(*, user_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}), + ) + + 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 UserSocialType.linux_do: + from backend.plugin.oauth2.api.v1.linux_do import linux_do_client + + auth_url = await linux_do_client.get_authorization_url( + redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI, + state=state, + ) + case _: + raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定') + + return auth_url + + +user_social_service: UserSocialService = UserSocialService()