Skip to content

Commit 425bc20

Browse files
authored
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
1 parent 3b24dca commit 425bc20

File tree

13 files changed

+339
-82
lines changed

13 files changed

+339
-82
lines changed

backend/core/conf.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,10 +231,13 @@ class Settings(BaseSettings):
231231
OAUTH2_LINUX_DO_CLIENT_SECRET: str
232232

233233
# 基础配置
234+
OAUTH2_STATE_REDIS_PREFIX: str = 'fba:oauth2:state'
235+
OAUTH2_STATE_EXPIRE_SECONDS: int = 60 * 3 # 3 分钟
234236
OAUTH2_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/github/callback'
235237
OAUTH2_GOOGLE_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/google/callback'
236238
OAUTH2_LINUX_DO_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/oauth2/linux-do/callback'
237-
OAUTH2_FRONTEND_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
239+
OAUTH2_FRONTEND_LOGIN_REDIRECT_URI: str = 'http://localhost:5173/oauth2/callback'
240+
OAUTH2_FRONTEND_BINDING_REDIRECT_URI: str = 'http://localhost:5173/profile'
238241

239242
##################################################
240243
# [ Plugin ] email

backend/plugin/oauth2/api/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from backend.plugin.oauth2.api.v1.github import router as github_router
55
from backend.plugin.oauth2.api.v1.google import router as google_router
66
from backend.plugin.oauth2.api.v1.linux_do import router as linux_do_router
7+
from backend.plugin.oauth2.api.v1.user_social import router as user_social_router
78

89
v1 = APIRouter(prefix=f'{settings.FASTAPI_API_V1_PATH}/oauth2')
910

11+
v1.include_router(user_social_router, tags=['OAuth2'])
1012
v1.include_router(github_router, prefix='/github', tags=['Github OAuth2'])
1113
v1.include_router(google_router, prefix='/google', tags=['Google OAuth2'])
1214
v1.include_router(linux_do_router, prefix='/linux-do', tags=['LinuxDo OAuth2'])

backend/plugin/oauth2/api/v1/github.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import json
2+
import uuid
3+
14
from typing import Annotated
25

36
from fastapi import APIRouter, BackgroundTasks, Depends, Response
@@ -8,7 +11,8 @@
811
from backend.common.response.response_schema import ResponseSchemaModel, response_base
912
from backend.core.conf import settings
1013
from backend.database.db import CurrentSessionTransaction
11-
from backend.plugin.oauth2.enums import UserSocialType
14+
from backend.database.redis import redis_client
15+
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
1216
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
1317

1418
router = APIRouter()
@@ -18,7 +22,15 @@
1822

1923
@router.get('', summary='获取 Github 授权链接')
2024
async def get_github_oauth2_url() -> ResponseSchemaModel[str]:
21-
auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)
25+
state = str(uuid.uuid4())
26+
27+
await redis_client.setex(
28+
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
29+
settings.OAUTH2_STATE_EXPIRE_SECONDS,
30+
json.dumps({'type': UserSocialAuthType.login.value}),
31+
)
32+
33+
auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI, state=state)
2234
return response_base.success(data=auth_url)
2335

2436

@@ -37,16 +49,23 @@ async def github_oauth2_callback( # noqa: ANN201
3749
Depends(FastAPIOAuth20(github_client, redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)),
3850
],
3951
):
40-
token_data, _state = oauth2
52+
token_data, state = oauth2
4153
access_token = token_data['access_token']
4254
user = await github_client.get_userinfo(access_token)
43-
data = await oauth2_service.create_with_login(
55+
data = await oauth2_service.login_or_binding(
4456
db=db,
4557
response=response,
4658
background_tasks=background_tasks,
4759
user=user,
4860
social=UserSocialType.github,
61+
state=state,
4962
)
63+
64+
# 绑定流程
65+
if data is None:
66+
return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI)
67+
68+
# 登录流程
5069
return RedirectResponse(
51-
url=f'{settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
70+
url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
5271
)

backend/plugin/oauth2/api/v1/google.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import json
2+
import uuid
3+
14
from typing import Annotated
25

36
from fastapi import APIRouter, BackgroundTasks, Depends, Response
@@ -8,7 +11,8 @@
811
from backend.common.response.response_schema import ResponseSchemaModel, response_base
912
from backend.core.conf import settings
1013
from backend.database.db import CurrentSessionTransaction
11-
from backend.plugin.oauth2.enums import UserSocialType
14+
from backend.database.redis import redis_client
15+
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
1216
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
1317

1418
router = APIRouter()
@@ -18,7 +22,15 @@
1822

1923
@router.get('', summary='获取 google 授权链接')
2024
async def get_google_oauth2_url() -> ResponseSchemaModel[str]:
21-
auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI)
25+
state = str(uuid.uuid4())
26+
27+
await redis_client.setex(
28+
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
29+
settings.OAUTH2_STATE_EXPIRE_SECONDS,
30+
json.dumps({'type': UserSocialAuthType.login.value}),
31+
)
32+
33+
auth_url = await google_client.get_authorization_url(redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI, state=state)
2234
return response_base.success(data=auth_url)
2335

2436

@@ -37,16 +49,23 @@ async def google_oauth2_callback( # noqa: ANN201
3749
Depends(FastAPIOAuth20(google_client, redirect_uri=settings.OAUTH2_GOOGLE_REDIRECT_URI)),
3850
],
3951
):
40-
token_data, _state = oauth2
52+
token_data, state = oauth2
4153
access_token = token_data['access_token']
4254
user = await google_client.get_userinfo(access_token)
43-
data = await oauth2_service.create_with_login(
55+
data = await oauth2_service.login_or_binding(
4456
db=db,
4557
response=response,
4658
background_tasks=background_tasks,
4759
user=user,
4860
social=UserSocialType.google,
61+
state=state,
4962
)
63+
64+
# 绑定流程
65+
if data is None:
66+
return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI)
67+
68+
# 登录流程
5069
return RedirectResponse(
51-
url=f'{settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
70+
url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
5271
)

backend/plugin/oauth2/api/v1/linux_do.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import json
2+
import uuid
3+
14
from typing import Annotated
25

36
from fastapi import APIRouter, BackgroundTasks, Depends, Response
@@ -8,7 +11,8 @@
811
from backend.common.response.response_schema import ResponseSchemaModel, response_base
912
from backend.core.conf import settings
1013
from backend.database.db import CurrentSessionTransaction
11-
from backend.plugin.oauth2.enums import UserSocialType
14+
from backend.database.redis import redis_client
15+
from backend.plugin.oauth2.enums import UserSocialAuthType, UserSocialType
1216
from backend.plugin.oauth2.service.oauth2_service import oauth2_service
1317

1418
router = APIRouter()
@@ -18,7 +22,17 @@
1822

1923
@router.get('', summary='获取 LinuxDo 授权链接')
2024
async def get_linux_do_oauth2_url() -> ResponseSchemaModel[str]:
21-
auth_url = await linux_do_client.get_authorization_url(redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)
25+
state = str(uuid.uuid4())
26+
27+
await redis_client.setex(
28+
f'{settings.OAUTH2_STATE_REDIS_PREFIX}:{state}',
29+
settings.OAUTH2_STATE_EXPIRE_SECONDS,
30+
json.dumps({'type': UserSocialAuthType.login.value}),
31+
)
32+
33+
auth_url = await linux_do_client.get_authorization_url(
34+
redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI, state=state
35+
)
2236
return response_base.success(data=auth_url)
2337

2438

@@ -37,16 +51,23 @@ async def linux_do_oauth2_callback( # noqa: ANN201
3751
Depends(FastAPIOAuth20(linux_do_client, redirect_uri=settings.OAUTH2_LINUX_DO_REDIRECT_URI)),
3852
],
3953
):
40-
token_data, _state = oauth2
54+
token_data, state = oauth2
4155
access_token = token_data['access_token']
4256
user = await linux_do_client.get_userinfo(access_token)
43-
data = await oauth2_service.create_with_login(
57+
data = await oauth2_service.login_or_binding(
4458
db=db,
4559
response=response,
4660
background_tasks=background_tasks,
4761
user=user,
4862
social=UserSocialType.linux_do,
63+
state=state,
4964
)
65+
66+
# 绑定流程
67+
if data is None:
68+
return RedirectResponse(url=settings.OAUTH2_FRONTEND_BINDING_REDIRECT_URI)
69+
70+
# 登录流程
5071
return RedirectResponse(
51-
url=f'{settings.OAUTH2_FRONTEND_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
72+
url=f'{settings.OAUTH2_FRONTEND_LOGIN_REDIRECT_URI}?access_token={data.access_token}&session_uuid={data.session_uuid}',
5273
)
Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
from fastapi import APIRouter, Request
22

3-
from backend.common.response.response_schema import ResponseModel, response_base
3+
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
44
from backend.common.security.jwt import DependsJwtAuth
5-
from backend.database.db import CurrentSessionTransaction
5+
from backend.database.db import CurrentSession, CurrentSessionTransaction
66
from backend.plugin.oauth2.enums import UserSocialType
7-
from backend.plugin.oauth2.service.user_social import user_social_service
7+
from backend.plugin.oauth2.service.user_social_service import user_social_service
88

99
router = APIRouter()
1010

1111

12-
@router.delete('/me', summary='解绑用户社交账号', dependencies=[DependsJwtAuth])
12+
@router.get('/me/bindings', summary='获取用户已绑定的社交账号', dependencies=[DependsJwtAuth])
13+
async def get_user_bindings(db: CurrentSession, request: Request) -> ResponseSchemaModel[list[str]]:
14+
bindings = await user_social_service.get_bindings(db=db, user_id=request.user.id)
15+
return response_base.success(data=bindings)
16+
17+
18+
@router.get('/me/binding', summary='获取绑定授权链接', dependencies=[DependsJwtAuth])
19+
async def get_binding_auth_url(request: Request, source: UserSocialType) -> ResponseSchemaModel[str]:
20+
binding_url = await user_social_service.get_binding_auth_url(user_id=request.user.id, source=source)
21+
return response_base.success(data=binding_url)
22+
23+
24+
@router.delete('/me/unbinding', summary='解绑用户社交账号', dependencies=[DependsJwtAuth])
1325
async def unbinding_user(db: CurrentSessionTransaction, request: Request, source: UserSocialType) -> ResponseModel:
1426
await user_social_service.unbinding(db=db, user_id=request.user.id, source=source)
1527
return response_base.success()

backend/plugin/oauth2/crud/crud_user_social.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections.abc import Sequence
2+
13
from sqlalchemy.ext.asyncio import AsyncSession
24
from sqlalchemy_crud_plus import CRUDPlus
35

@@ -24,12 +26,22 @@ async def get_by_sid(self, db: AsyncSession, sid: str, source: str) -> UserSocia
2426
通过 sid 获取社交用户
2527
2628
:param db: 数据库会话
27-
:param sid: 第三方用户唯一编码
29+
:param sid: 社交账号唯一编码
2830
:param source: 社交账号类型
2931
:return:
3032
"""
3133
return await self.select_model_by_column(db, sid=sid, source=source)
3234

35+
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserSocial]:
36+
"""
37+
通过用户 ID 获取所有社交账号绑定
38+
39+
:param db: 数据库会话
40+
:param user_id: 用户 ID
41+
:return:
42+
"""
43+
return await self.select_models(db, user_id=user_id)
44+
3345
async def create(self, db: AsyncSession, obj: CreateUserSocialParam) -> None:
3446
"""
3547
创建用户社交账号绑定

backend/plugin/oauth2/enums.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
class UserSocialType(StrEnum):
55
"""用户社交类型"""
66

7-
github = 'GitHub'
7+
github = 'Github'
88
google = 'Google'
99
linux_do = 'LinuxDo'
10+
11+
12+
class UserSocialAuthType(StrEnum):
13+
"""用户社交授权类型"""
14+
15+
login = 'login'
16+
binding = 'binding'

backend/plugin/oauth2/plugin.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[plugin]
22
summary = 'OAuth 2.0'
3-
version = '0.0.9'
3+
version = '0.0.10'
44
description = '通过 OAuth 2.0 的方式登录系统'
55
author = 'wu-clan'
66

backend/plugin/oauth2/schema/user_social.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pydantic import Field
1+
from pydantic import ConfigDict, Field
22

33
from backend.common.schema import SchemaBase
44
from backend.plugin.oauth2.enums import UserSocialType
@@ -19,3 +19,11 @@ class CreateUserSocialParam(UserSocialSchemaBase):
1919

2020
class UpdateUserSocialParam(SchemaBase):
2121
"""更新用户社交参数"""
22+
23+
24+
class GetUserSocialDetail(CreateUserSocialParam):
25+
"""获取用户社交详情"""
26+
27+
model_config = ConfigDict(from_attributes=True)
28+
29+
id: int = Field(description='用户社交 ID')

0 commit comments

Comments
 (0)