From 8a7335ec747e5149b913a3fc3ea438bd040d7922 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 13 Nov 2025 23:50:35 +0800 Subject: [PATCH 1/6] Add user social binding and unbinding --- backend/core/conf.py | 3 +- backend/plugin/oauth2/api/router.py | 2 + backend/plugin/oauth2/api/v1/github.py | 13 +- backend/plugin/oauth2/api/v1/google.py | 13 +- backend/plugin/oauth2/api/v1/linux_do.py | 13 +- backend/plugin/oauth2/api/v1/user_social.py | 21 ++- .../plugin/oauth2/crud/crud_user_social.py | 14 +- backend/plugin/oauth2/plugin.toml | 2 +- backend/plugin/oauth2/schema/user_social.py | 10 +- .../plugin/oauth2/service/oauth2_service.py | 125 +++++++++++++----- backend/plugin/oauth2/service/user_social.py | 25 ---- .../oauth2/service/user_social_service.py | 97 ++++++++++++++ 12 files changed, 264 insertions(+), 74 deletions(-) delete mode 100644 backend/plugin/oauth2/service/user_social.py create mode 100644 backend/plugin/oauth2/service/user_social_service.py diff --git a/backend/core/conf.py b/backend/core/conf.py index fecd35c1f..5f15f1727 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -234,7 +234,8 @@ class Settings(BaseSettings): 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 53f37c8ce..a827968cb 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 20cd0e61f..4d0452524 100644 --- a/backend/plugin/oauth2/api/v1/github.py +++ b/backend/plugin/oauth2/api/v1/github.py @@ -37,16 +37,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 0456b9218..a004678c9 100644 --- a/backend/plugin/oauth2/api/v1/google.py +++ b/backend/plugin/oauth2/api/v1/google.py @@ -37,16 +37,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 97f2f87b2..54659ef70 100644 --- a/backend/plugin/oauth2/api/v1/linux_do.py +++ b/backend/plugin/oauth2/api/v1/linux_do.py @@ -37,16 +37,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 0f81628d1..52bba6f21 100644 --- a/backend/plugin/oauth2/api/v1/user_social.py +++ b/backend/plugin/oauth2/api/v1/user_social.py @@ -1,15 +1,28 @@ 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.schema.user_social import GetUserSocialDetail +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[GetUserSocialDetail]: + bindings = await user_social_service.get_bindings(db=db, user_id=request.user.id) + return response_base.success(data=bindings) + + +@router.get('/me/binding/{source}', summary='获取绑定授权链接', dependencies=[DependsJwtAuth]) +async def get_binding_auth_url(request: Request, source: UserSocialType) -> ResponseSchemaModel[str]: + binding_url = 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 ab7c6f7c6..10bca224a 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/plugin.toml b/backend/plugin/oauth2/plugin.toml index c395a5534..92a22f206 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 3e2ece918..9585a38d5 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 cfe7f8bd3..a5030b1d8 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,6 +12,7 @@ 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 @@ -17,6 +20,7 @@ from backend.plugin.oauth2.crud.crud_user_social import user_social_dao from backend.plugin.oauth2.enums import 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,76 @@ 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 登录') + + state = json.loads(state) + + # 绑定流程 + if state and state.get('type') == 'binding': + user_id = state.get('user_id') + if user_id: + await user_social_service.binding_with_oauth2( + db=db, + user_id=user_id, + sid=str(sid), + source=social, + ) + return None + + # 登录流程 + 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 4230de88f..000000000 --- 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 000000000..2eb24e6b9 --- /dev/null +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -0,0 +1,97 @@ +import json + +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.common.exception import errors +from backend.core.conf import settings +from backend.plugin.oauth2.api.v1.github import github_client +from backend.plugin.oauth2.api.v1.google import google_client +from backend.plugin.oauth2.api.v1.linux_do import linux_do_client +from backend.plugin.oauth2.crud.crud_user_social import user_social_dao +from backend.plugin.oauth2.enums import UserSocialType +from backend.plugin.oauth2.model import UserSocial +from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam + + +class UserSocialService: + @staticmethod + async def get_bindings(*, db: AsyncSession, user_id: int) -> Sequence[UserSocial]: + """ + 获取用户已绑定的社交账号 + + :param db: 数据库会话 + :param user_id: 用户 ID + :return: 绑定列表,每个元素包含 sid、source 等信息 + """ + return await user_social_dao.get_by_user_id(db, user_id) + + @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 = json.dumps({'type': 'binding', 'user_id': user_id}) + + match source: + case UserSocialType.github: + auth_url = await github_client.get_authorization_url( + redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, + state=state, + ) + case UserSocialType.google: + auth_url = await google_client.get_authorization_url( + redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, + state=state, + ) + case UserSocialType.linux_do: + 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() From 6fae9e7052171ccd12f7540e9a1bd6baa97f644e Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Fri, 14 Nov 2025 12:22:59 +0800 Subject: [PATCH 2/6] Add oauth2 state to binding --- backend/core/conf.py | 2 ++ .../plugin/oauth2/service/oauth2_service.py | 28 +++++++++++-------- .../oauth2/service/user_social_service.py | 20 ++++++++++--- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/backend/core/conf.py b/backend/core/conf.py index 5f15f1727..b7ac0da87 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -231,6 +231,8 @@ 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' diff --git a/backend/plugin/oauth2/service/oauth2_service.py b/backend/plugin/oauth2/service/oauth2_service.py index a5030b1d8..e2f1634e2 100644 --- a/backend/plugin/oauth2/service/oauth2_service.py +++ b/backend/plugin/oauth2/service/oauth2_service.py @@ -174,19 +174,25 @@ async def login_or_binding( case _: raise errors.ForbiddenError(msg=f'暂不支持 {social} OAuth2 登录') - state = json.loads(state) + state_info = None + if state: + state_data = await redis_client.get(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') + if state_data: + state_info = json.loads(state_data) + await redis_client.delete(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') # 绑定流程 - if state and state.get('type') == 'binding': - user_id = state.get('user_id') - if user_id: - await user_social_service.binding_with_oauth2( - db=db, - user_id=user_id, - sid=str(sid), - source=social, - ) - return None + if state_info and state_info.get('type') == 'binding': + 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 # 登录流程 return await self.login( diff --git a/backend/plugin/oauth2/service/user_social_service.py b/backend/plugin/oauth2/service/user_social_service.py index 2eb24e6b9..2527e719b 100644 --- a/backend/plugin/oauth2/service/user_social_service.py +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -1,4 +1,6 @@ import json +import uuid +from datetime import datetime from collections.abc import Sequence @@ -6,6 +8,7 @@ from backend.common.exception import errors from backend.core.conf import settings +from backend.database.redis import redis_client from backend.plugin.oauth2.api.v1.github import github_client from backend.plugin.oauth2.api.v1.google import google_client from backend.plugin.oauth2.api.v1.linux_do import linux_do_client @@ -70,23 +73,32 @@ async def unbinding(*, db: AsyncSession, user_id: int, source: UserSocialType) - @staticmethod async def get_binding_auth_url(*, user_id: int, source: UserSocialType) -> str: - state = json.dumps({'type': 'binding', 'user_id': user_id}) + state_token = str(uuid.uuid4()) + state_data = { + 'type': 'binding', + 'user_id': user_id + } + await redis_client.setex( + f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state_token}', + settings.OAUTH2_STATE_EXPIRE_SECONDS, + json.dumps(state_data) + ) match source: case UserSocialType.github: auth_url = await github_client.get_authorization_url( redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, - state=state, + state=state_token, ) case UserSocialType.google: auth_url = await google_client.get_authorization_url( redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, - state=state, + state=state_token, ) case UserSocialType.linux_do: auth_url = await linux_do_client.get_authorization_url( redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI, - state=state, + state=state_token, ) case _: raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定') From 6335cd6f6bdb37051ff5aab04309dfd11094c85d Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Fri, 14 Nov 2025 14:27:36 +0800 Subject: [PATCH 3/6] Update oauth2 state --- backend/plugin/oauth2/api/v1/github.py | 14 ++++++++++++- backend/plugin/oauth2/api/v1/google.py | 14 ++++++++++++- backend/plugin/oauth2/api/v1/linux_do.py | 16 ++++++++++++++- .../plugin/oauth2/service/oauth2_service.py | 20 ++++++++++++------- .../oauth2/service/user_social_service.py | 18 +++++++---------- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/backend/plugin/oauth2/api/v1/github.py b/backend/plugin/oauth2/api/v1/github.py index 4d0452524..c66afff51 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,6 +11,7 @@ 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 UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service @@ -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': 'login'}), + ) + + auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, state=state) return response_base.success(data=auth_url) diff --git a/backend/plugin/oauth2/api/v1/google.py b/backend/plugin/oauth2/api/v1/google.py index a004678c9..3dc449a12 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,6 +11,7 @@ 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 UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service @@ -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': 'login'}), + ) + + auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, state=state) return response_base.success(data=auth_url) diff --git a/backend/plugin/oauth2/api/v1/linux_do.py b/backend/plugin/oauth2/api/v1/linux_do.py index 54659ef70..c3d638542 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,6 +11,7 @@ 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 UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service @@ -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': 'login'}), + ) + + 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) diff --git a/backend/plugin/oauth2/service/oauth2_service.py b/backend/plugin/oauth2/service/oauth2_service.py index e2f1634e2..9268adc6b 100644 --- a/backend/plugin/oauth2/service/oauth2_service.py +++ b/backend/plugin/oauth2/service/oauth2_service.py @@ -174,15 +174,18 @@ async def login_or_binding( case _: raise errors.ForbiddenError(msg=f'暂不支持 {social} OAuth2 登录') - state_info = None - if state: - state_data = await redis_client.get(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') - if state_data: - state_info = json.loads(state_data) - await redis_client.delete(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') + 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 and state_info.get('type') == 'binding': + if state_info.get('type') == 'binding': user_id = state_info.get('user_id') if not user_id: raise errors.ForbiddenError(msg='非法操作,OAuth2 状态信息无效') @@ -195,6 +198,9 @@ async def login_or_binding( return None # 登录流程 + if state_info.get('type') != 'login': + raise errors.ForbiddenError(msg='OAuth2 状态信息无效') + return await self.login( db=db, response=response, diff --git a/backend/plugin/oauth2/service/user_social_service.py b/backend/plugin/oauth2/service/user_social_service.py index 2527e719b..b104494a5 100644 --- a/backend/plugin/oauth2/service/user_social_service.py +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -1,6 +1,5 @@ import json import uuid -from datetime import datetime from collections.abc import Sequence @@ -73,32 +72,29 @@ async def unbinding(*, db: AsyncSession, user_id: int, source: UserSocialType) - @staticmethod async def get_binding_auth_url(*, user_id: int, source: UserSocialType) -> str: - state_token = str(uuid.uuid4()) - state_data = { - 'type': 'binding', - 'user_id': user_id - } + state = str(uuid.uuid4()) + await redis_client.setex( - f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state_token}', + f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}', settings.OAUTH2_STATE_EXPIRE_SECONDS, - json.dumps(state_data) + json.dumps({'type': 'binding', 'user_id': user_id}), ) match source: case UserSocialType.github: auth_url = await github_client.get_authorization_url( redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, - state=state_token, + state=state, ) case UserSocialType.google: auth_url = await google_client.get_authorization_url( redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, - state=state_token, + state=state, ) case UserSocialType.linux_do: auth_url = await linux_do_client.get_authorization_url( redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI, - state=state_token, + state=state, ) case _: raise errors.ForbiddenError(msg=f'暂不支持 {source} 绑定') From 98e09ce62e3d8ab1a2e5f0706d006b4af2ab4a5e Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Fri, 14 Nov 2025 15:32:44 +0800 Subject: [PATCH 4/6] Fix imports --- backend/plugin/oauth2/service/user_social_service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/plugin/oauth2/service/user_social_service.py b/backend/plugin/oauth2/service/user_social_service.py index b104494a5..1ac571d61 100644 --- a/backend/plugin/oauth2/service/user_social_service.py +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -8,9 +8,6 @@ from backend.common.exception import errors from backend.core.conf import settings from backend.database.redis import redis_client -from backend.plugin.oauth2.api.v1.github import github_client -from backend.plugin.oauth2.api.v1.google import google_client -from backend.plugin.oauth2.api.v1.linux_do import linux_do_client from backend.plugin.oauth2.crud.crud_user_social import user_social_dao from backend.plugin.oauth2.enums import UserSocialType from backend.plugin.oauth2.model import UserSocial @@ -82,16 +79,22 @@ async def get_binding_auth_url(*, user_id: int, source: UserSocialType) -> str: 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, From 4917078b1d35b20043737d79c144f5fb5513ce2b Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sat, 15 Nov 2025 16:55:51 +0800 Subject: [PATCH 5/6] Update some interface definitions --- backend/plugin/oauth2/api/v1/user_social.py | 7 +++---- backend/plugin/oauth2/enums.py | 2 +- backend/plugin/oauth2/service/user_social_service.py | 8 +++----- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/plugin/oauth2/api/v1/user_social.py b/backend/plugin/oauth2/api/v1/user_social.py index 52bba6f21..7041e5d38 100644 --- a/backend/plugin/oauth2/api/v1/user_social.py +++ b/backend/plugin/oauth2/api/v1/user_social.py @@ -4,21 +4,20 @@ from backend.common.security.jwt import DependsJwtAuth from backend.database.db import CurrentSession, CurrentSessionTransaction from backend.plugin.oauth2.enums import UserSocialType -from backend.plugin.oauth2.schema.user_social import GetUserSocialDetail from backend.plugin.oauth2.service.user_social_service import user_social_service router = APIRouter() @router.get('/me/bindings', summary='获取用户已绑定的社交账号', dependencies=[DependsJwtAuth]) -async def get_user_bindings(db: CurrentSession, request: Request) -> ResponseSchemaModel[GetUserSocialDetail]: +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/{source}', summary='获取绑定授权链接', dependencies=[DependsJwtAuth]) +@router.get('/me/binding', summary='获取绑定授权链接', dependencies=[DependsJwtAuth]) async def get_binding_auth_url(request: Request, source: UserSocialType) -> ResponseSchemaModel[str]: - binding_url = 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, source=source) return response_base.success(data=binding_url) diff --git a/backend/plugin/oauth2/enums.py b/backend/plugin/oauth2/enums.py index abd68bd6e..b3193b56d 100644 --- a/backend/plugin/oauth2/enums.py +++ b/backend/plugin/oauth2/enums.py @@ -4,6 +4,6 @@ class UserSocialType(StrEnum): """用户社交类型""" - github = 'GitHub' + github = 'Github' google = 'Google' linux_do = 'LinuxDo' diff --git a/backend/plugin/oauth2/service/user_social_service.py b/backend/plugin/oauth2/service/user_social_service.py index 1ac571d61..cbe44af00 100644 --- a/backend/plugin/oauth2/service/user_social_service.py +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -1,8 +1,6 @@ import json import uuid -from collections.abc import Sequence - from sqlalchemy.ext.asyncio import AsyncSession from backend.common.exception import errors @@ -10,13 +8,12 @@ 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.model import UserSocial from backend.plugin.oauth2.schema.user_social import CreateUserSocialParam class UserSocialService: @staticmethod - async def get_bindings(*, db: AsyncSession, user_id: int) -> Sequence[UserSocial]: + async def get_bindings(*, db: AsyncSession, user_id: int) -> list[str]: """ 获取用户已绑定的社交账号 @@ -24,7 +21,8 @@ async def get_bindings(*, db: AsyncSession, user_id: int) -> Sequence[UserSocial :param user_id: 用户 ID :return: 绑定列表,每个元素包含 sid、source 等信息 """ - return await user_social_dao.get_by_user_id(db, user_id) + 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( From 1e5ac13819b5938cbe9e6f77c6908187507c49c4 Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Sat, 15 Nov 2025 17:02:06 +0800 Subject: [PATCH 6/6] Update the authorization type security --- backend/plugin/oauth2/api/v1/github.py | 4 ++-- backend/plugin/oauth2/api/v1/google.py | 4 ++-- backend/plugin/oauth2/api/v1/linux_do.py | 4 ++-- backend/plugin/oauth2/enums.py | 7 +++++++ backend/plugin/oauth2/service/oauth2_service.py | 6 +++--- backend/plugin/oauth2/service/user_social_service.py | 4 ++-- 6 files changed, 18 insertions(+), 11 deletions(-) diff --git a/backend/plugin/oauth2/api/v1/github.py b/backend/plugin/oauth2/api/v1/github.py index c66afff51..4bc35fb7f 100644 --- a/backend/plugin/oauth2/api/v1/github.py +++ b/backend/plugin/oauth2/api/v1/github.py @@ -12,7 +12,7 @@ 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 UserSocialType +from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service router = APIRouter() @@ -27,7 +27,7 @@ async def get_github_oauth2_url() -> ResponseSchemaModel[str]: await redis_client.setex( f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}', settings.OAUTH2_STATE_EXPIRE_SECONDS, - json.dumps({'type': 'login'}), + json.dumps({'type': UserSocialAuthType.login.value}), ) auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, state=state) diff --git a/backend/plugin/oauth2/api/v1/google.py b/backend/plugin/oauth2/api/v1/google.py index 3dc449a12..77eb7c6d4 100644 --- a/backend/plugin/oauth2/api/v1/google.py +++ b/backend/plugin/oauth2/api/v1/google.py @@ -12,7 +12,7 @@ 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 UserSocialType +from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service router = APIRouter() @@ -27,7 +27,7 @@ async def get_google_oauth2_url() -> ResponseSchemaModel[str]: await redis_client.setex( f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}', settings.OAUTH2_STATE_EXPIRE_SECONDS, - json.dumps({'type': 'login'}), + json.dumps({'type': UserSocialAuthType.login.value}), ) auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, state=state) diff --git a/backend/plugin/oauth2/api/v1/linux_do.py b/backend/plugin/oauth2/api/v1/linux_do.py index c3d638542..73420fbc4 100644 --- a/backend/plugin/oauth2/api/v1/linux_do.py +++ b/backend/plugin/oauth2/api/v1/linux_do.py @@ -12,7 +12,7 @@ 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 UserSocialType +from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType from backend.plugin.oauth2.service.oauth2_service import oauth2_service router = APIRouter() @@ -27,7 +27,7 @@ async def get_linux_do_oauth2_url() -> ResponseSchemaModel[str]: await redis_client.setex( f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}', settings.OAUTH2_STATE_EXPIRE_SECONDS, - json.dumps({'type': 'login'}), + json.dumps({'type': UserSocialAuthType.login.value}), ) auth_url = await linux_do_client.get_authorization_url( diff --git a/backend/plugin/oauth2/enums.py b/backend/plugin/oauth2/enums.py index b3193b56d..5c69af552 100644 --- a/backend/plugin/oauth2/enums.py +++ b/backend/plugin/oauth2/enums.py @@ -7,3 +7,10 @@ class UserSocialType(StrEnum): github = 'Github' google = 'Google' linux_do = 'LinuxDo' + + +class UserSocialAuthType(StrEnum): + """用户社交授权类型""" + + login = 'login' + binding = 'binding' diff --git a/backend/plugin/oauth2/service/oauth2_service.py b/backend/plugin/oauth2/service/oauth2_service.py index 9268adc6b..c5f85686d 100644 --- a/backend/plugin/oauth2/service/oauth2_service.py +++ b/backend/plugin/oauth2/service/oauth2_service.py @@ -18,7 +18,7 @@ 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 @@ -185,7 +185,7 @@ async def login_or_binding( await redis_client.delete(f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}') # 绑定流程 - if state_info.get('type') == 'binding': + if state_info.get('type') == UserSocialAuthType.binding.value: user_id = state_info.get('user_id') if not user_id: raise errors.ForbiddenError(msg='非法操作,OAuth2 状态信息无效') @@ -198,7 +198,7 @@ async def login_or_binding( return None # 登录流程 - if state_info.get('type') != 'login': + if state_info.get('type') != UserSocialAuthType.login.value: raise errors.ForbiddenError(msg='OAuth2 状态信息无效') return await self.login( diff --git a/backend/plugin/oauth2/service/user_social_service.py b/backend/plugin/oauth2/service/user_social_service.py index cbe44af00..0688d6ff9 100644 --- a/backend/plugin/oauth2/service/user_social_service.py +++ b/backend/plugin/oauth2/service/user_social_service.py @@ -7,7 +7,7 @@ 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 @@ -72,7 +72,7 @@ async def get_binding_auth_url(*, user_id: int, source: UserSocialType) -> str: await redis_client.setex( f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}', settings.OAUTH2_STATE_EXPIRE_SECONDS, - json.dumps({'type': 'binding', 'user_id': user_id}), + json.dumps({'type': UserSocialAuthType.binding.value, 'user_id': user_id}), ) match source: