Add user social binding and unbinding (#919)

* Add user social binding and unbinding

* Add oauth2 state to binding

* Update oauth2 state

* Fix imports

* Update some interface definitions

* Update the authorization type security
This commit is contained in:
Wu Clan
2025-11-15 17:03:30 +08:00
committed by GitHub
parent 3b24dca935
commit 425bc202c2
13 changed files with 339 additions and 82 deletions
+2
View File
@@ -4,9 +4,11 @@ from backend.core.conf import settings
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'])
+24 -5
View File
@@ -1,3 +1,6 @@
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Response
@@ -8,7 +11,8 @@ 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.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 @@ github_client = GitHubOAuth20(settings.OAUTH2_GITHUB_CLIENT_ID, settings.OAUTH2_
@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}',
)
+24 -5
View File
@@ -1,3 +1,6 @@
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Response
@@ -8,7 +11,8 @@ 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.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 @@ google_client = GoogleOAuth20(settings.OAUTH2_GOOGLE_CLIENT_ID, settings.OAUTH2_
@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}',
)
+26 -5
View File
@@ -1,3 +1,6 @@
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Response
@@ -8,7 +11,8 @@ 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.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 @@ linux_do_client = LinuxDoOAuth20(settings.OAUTH2_LINUX_DO_CLIENT_ID, settings.OA
@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}',
)
+16 -4
View File
@@ -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()
+13 -1
View File
@@ -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 @@ class CRUDUserSocial(CRUDPlus[UserSocial]):
通过 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:
"""
创建用户社交账号绑定
+8 -1
View File
@@ -4,6 +4,13 @@ from backend.common.enums import StrEnum
class UserSocialType(StrEnum):
"""用户社交类型"""
github = 'GitHub'
github = 'Github'
google = 'Google'
linux_do = 'LinuxDo'
class UserSocialAuthType(StrEnum):
"""用户社交授权类型"""
login = 'login'
binding = 'binding'
+1 -1
View File
@@ -1,6 +1,6 @@
[plugin]
summary = 'OAuth 2.0'
version = '0.0.9'
version = '0.0.10'
description = '通过 OAuth 2.0 的方式登录系统'
author = 'wu-clan'
+9 -1
View File
@@ -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')
+106 -33
View File
@@ -1,3 +1,5 @@
import json
from typing import Any
from fast_captcha import text_captcha
@@ -10,13 +12,15 @@ from backend.app.admin.schema.user import AddOAuth2UserParam
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 @@ class OAuth2Service:
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 @@ class OAuth2Service:
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 @@ class OAuth2Service:
)
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()
@@ -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()
@@ -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()