Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OAuth 2.0 authorization login #293

Merged
merged 17 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ __pycache__/
.idea/
.env
venv/
.venv/
.mypy_cache/
backend/app/log/
backend/app/alembic/versions/
Expand Down
3 changes: 3 additions & 0 deletions backend/app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ RABBITMQ_PASSWORD='guest'
TOKEN_SECRET_KEY='1VkVF75nsNABBjK_7-qz7GtzNy3AMvktc9TCPwKczCk'
# Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY='d77b25790a804c2b4a339dd0207941e4cefa5751935a33735bc73bb7071a005b'
# OAuth2
OAUTH2_GITHUB_CLIENT_ID='test'
OAUTH2_GITHUB_CLIENT_SECRET='test'
2 changes: 2 additions & 0 deletions backend/app/api/v1/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

from backend.app.api.v1.auth.auth import router as auth_router
from backend.app.api.v1.auth.captcha import router as captcha_router
from backend.app.api.v1.auth.github import router as github_router

router = APIRouter(prefix='/auth', tags=['授权管理'])

router.include_router(auth_router)
router.include_router(captcha_router)
router.include_router(github_router)
39 changes: 7 additions & 32 deletions backend/app/api/v1/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,22 @@
from typing import Annotated

from fastapi import APIRouter, Depends, Query, Request
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.security import HTTPBasicCredentials
from fastapi_limiter.depends import RateLimiter
from starlette.background import BackgroundTasks

from backend.app.common.jwt import DependsJwtAuth
from backend.app.common.response.response_schema import ResponseModel, response_base
from backend.app.schemas.token import GetLoginToken, GetNewToken, GetSwaggerToken
from backend.app.schemas.token import GetSwaggerToken
from backend.app.schemas.user import AuthLoginParam
from backend.app.services.auth_service import auth_service

router = APIRouter()


@router.post(
'/swagger_login',
summary='swagger 表单登录',
description='form 格式登录,用于 swagger 文档调试以及获取 JWT Auth',
deprecated=True,
)
async def swagger_user_login(form_data: OAuth2PasswordRequestForm = Depends()) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(form_data=form_data)
@router.post('/login/swagger', summary='swagger 调试专用', description='用于快捷获取 token 进行 swagger 认证')
async def swagger_user_login(obj: Annotated[HTTPBasicCredentials, Depends()]) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(obj=obj)
return GetSwaggerToken(access_token=token, user=user) # type: ignore


Expand All @@ -34,33 +29,13 @@ async def swagger_user_login(form_data: OAuth2PasswordRequestForm = Depends()) -
dependencies=[Depends(RateLimiter(times=5, minutes=1))],
)
async def user_login(request: Request, obj: AuthLoginParam, background_tasks: BackgroundTasks) -> ResponseModel:
access_token, refresh_token, access_expire, refresh_expire, user = await auth_service.login(
request=request, obj=obj, background_tasks=background_tasks
)
data = GetLoginToken(
access_token=access_token,
refresh_token=refresh_token,
access_token_expire_time=access_expire,
refresh_token_expire_time=refresh_expire,
user=user, # type: ignore
)
data = await auth_service.login(request=request, obj=obj, background_tasks=background_tasks)
return await response_base.success(data=data)


@router.post('/new_token', summary='创建新 token', dependencies=[DependsJwtAuth])
async def create_new_token(request: Request, refresh_token: Annotated[str, Query(...)]) -> ResponseModel:
(
new_access_token,
new_refresh_token,
new_access_token_expire_time,
new_refresh_token_expire_time,
) = await auth_service.new_token(request=request, refresh_token=refresh_token)
data = GetNewToken(
access_token=new_access_token,
access_token_expire_time=new_access_token_expire_time,
refresh_token=new_refresh_token,
refresh_token_expire_time=new_refresh_token_expire_time,
)
data = await auth_service.new_token(request=request, refresh_token=refresh_token)
return await response_base.success(data=data)


Expand Down
35 changes: 35 additions & 0 deletions backend/app/api/v1/auth/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi_oauth20 import FastAPIOAuth20, GitHubOAuth20

from backend.app.common.response.response_schema import ResponseModel, response_base
from backend.app.core.conf import settings
from backend.app.services.github_service import github_service

router = APIRouter()

github_client = GitHubOAuth20(settings.OAUTH2_GITHUB_CLIENT_ID, settings.OAUTH2_GITHUB_CLIENT_SECRET)
github_oauth2 = FastAPIOAuth20(github_client, settings.OAUTH2_GITHUB_REDIRECT_URI)


@router.get('/github', summary='获取 Github 授权链接')
async def auth_github() -> ResponseModel:
auth_url = await github_client.get_authorization_url(redirect_uri=settings.OAUTH2_GITHUB_REDIRECT_URI)
return await response_base.success(data=auth_url)


@router.get(
'/github/callback',
summary='Github 授权重定向',
description='Github 授权后,自动重定向到当前地址并获取用户信息,通过用户信息自动创建系统用户',
response_model=None,
)
async def login_github(
request: Request, background_tasks: BackgroundTasks, oauth: FastAPIOAuth20 = Depends(github_oauth2)
) -> ResponseModel:
token, state = oauth
access_token = token['access_token']
user = await github_client.get_userinfo(access_token)
data = await github_service.add_with_login(request, background_tasks, user)
return await response_base.success(data=data)
6 changes: 6 additions & 0 deletions backend/app/common/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ class StatusType(IntEnum):

disable = 0
enable = 1


class UserSocialType(StrEnum):
"""用户社交类型"""

github = 'GitHub'
4 changes: 1 addition & 3 deletions backend/app/common/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from asgiref.sync import sync_to_async
from fastapi import Depends, Request
from fastapi.security import HTTPBearer, OAuth2PasswordBearer
from fastapi.security import HTTPBearer
from fastapi.security.utils import get_authorization_scheme_param
from jose import jwt
from passlib.context import CryptContext
Expand All @@ -19,8 +19,6 @@

pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')

# Deprecated, may be enabled when oauth2 is actually integrated
oauth2_schema = OAuth2PasswordBearer(tokenUrl=settings.TOKEN_URL_SWAGGER)

# JWT authorizes dependency injection
DependsJwtAuth = Depends(HTTPBearer())
Expand Down
7 changes: 7 additions & 0 deletions backend/app/core/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class Settings(BaseSettings):
# Env Opera Log
OPERA_LOG_ENCRYPT_SECRET_KEY: str # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str

# OAuth2
OAUTH2_GITHUB_CLIENT_ID: str
wu-clan marked this conversation as resolved.
Show resolved Hide resolved
OAUTH2_GITHUB_CLIENT_SECRET: str

# FastAPI
API_V1_STR: str = '/api/v1'
TITLE: str = 'FastAPI'
Expand All @@ -70,6 +74,9 @@ def validate_openapi_url(cls, values):
('GET', f'{API_V1_STR}/auth/captcha'),
}

# OAuth2
OAUTH2_GITHUB_REDIRECT_URI: str = 'http://127.0.0.1:8000/api/v1/auth/github/callback'

# Uvicorn
UVICORN_HOST: str = '127.0.0.1'
UVICORN_PORT: int = 8000
Expand Down
24 changes: 13 additions & 11 deletions backend/app/crud/crud_user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from datetime import datetime

from fast_captcha import text_captcha
from sqlalchemy import and_, desc, select, update
from sqlalchemy.ext.asyncio import AsyncSession
Expand All @@ -12,6 +10,7 @@
from backend.app.crud.base import CRUDBase
from backend.app.models import Role, User
from backend.app.schemas.user import AddUserParam, AvatarParam, RegisterUserParam, UpdateUserParam, UpdateUserRoleParam
from backend.app.utils.timezone import timezone


class CRUDUser(CRUDBase[User, RegisterUserParam, UpdateUserParam]):
Expand All @@ -26,24 +25,27 @@ async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
user = await db.execute(select(self.model).where(self.model.nickname == nickname))
return user.scalars().first()

async def update_login_time(self, db: AsyncSession, username: str, login_time: datetime) -> int:
async def update_login_time(self, db: AsyncSession, username: str) -> int:
user = await db.execute(
update(self.model).where(self.model.username == username).values(last_login_time=login_time)
update(self.model).where(self.model.username == username).values(last_login_time=timezone.now())
)
await db.commit()
return user.rowcount

async def create(self, db: AsyncSession, obj: RegisterUserParam) -> None:
salt = text_captcha(5)
obj.password = await jwt.get_hash_password(obj.password + salt)
dict_obj = obj.model_dump()
dict_obj.update({'salt': salt})
async def create(self, db: AsyncSession, obj: RegisterUserParam, *, social: bool = False) -> None:
if not social:
salt = text_captcha(5)
obj.password = await jwt.get_hash_password(f'{obj.password}{salt}')
dict_obj = obj.model_dump()
dict_obj.update({'salt': salt})
else:
dict_obj = obj.model_dump()
dict_obj.update({'salt': None})
new_user = self.model(**dict_obj)
db.add(new_user)

async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
salt = text_captcha(5)
obj.password = await jwt.get_hash_password(obj.password + salt)
obj.password = await jwt.get_hash_password(f'{obj.password}{salt}')
dict_obj = obj.model_dump(exclude={'roles'})
dict_obj.update({'salt': salt})
new_user = self.model(**dict_obj)
Expand Down
25 changes: 25 additions & 0 deletions backend/app/crud/crud_user_social.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession

from backend.app.common.enums import UserSocialType
from backend.app.crud.base import CRUDBase
from backend.app.models import UserSocial
from backend.app.schemas.user_social import CreateUserSocialParam, UpdateUserSocialParam


class CRUDOUserSocial(CRUDBase[UserSocial, CreateUserSocialParam, UpdateUserSocialParam]):
async def get(self, db: AsyncSession, pk: int, source: UserSocialType) -> UserSocial | None:
se = select(self.model).where(and_(self.model.id == pk, self.model.source == source))
user_social = await db.execute(se)
return user_social.scalars().first()

async def create(self, db: AsyncSession, obj_in: CreateUserSocialParam) -> None:
await self.create_(db, obj_in)

async def delete(self, db: AsyncSession, social_id: int) -> int:
return await self.delete_(db, social_id)


user_social_dao: CRUDOUserSocial = CRUDOUserSocial(UserSocial)
1 change: 1 addition & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
from backend.app.models.sys_opera_log import OperaLog
from backend.app.models.sys_role import Role
from backend.app.models.sys_user import User
from backend.app.models.sys_user_social import UserSocial
6 changes: 4 additions & 2 deletions backend/app/models/sys_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class User(Base):
uuid: Mapped[str] = mapped_column(String(50), init=False, default_factory=uuid4_str, unique=True)
username: Mapped[str] = mapped_column(String(20), unique=True, index=True, comment='用户名')
nickname: Mapped[str] = mapped_column(String(20), unique=True, comment='昵称')
password: Mapped[str] = mapped_column(String(255), comment='密码')
salt: Mapped[str] = mapped_column(String(5), comment='加密盐')
password: Mapped[str | None] = mapped_column(String(255), comment='密码')
salt: Mapped[str | None] = mapped_column(String(5), comment='加密盐')
email: Mapped[str] = mapped_column(String(50), unique=True, index=True, comment='邮箱')
is_superuser: Mapped[bool] = mapped_column(default=False, comment='超级权限(0否 1是)')
is_staff: Mapped[bool] = mapped_column(default=False, comment='后台管理登陆(0否 1是)')
Expand All @@ -41,3 +41,5 @@ class User(Base):
roles: Mapped[list['Role']] = relationship( # noqa: F821
init=False, secondary=sys_user_role, back_populates='users'
)
# 用户 OAuth2 一对多
socials: Mapped[list['UserSocial']] = relationship(init=False, back_populates='user') # noqa: F821
27 changes: 27 additions & 0 deletions backend/app/models/sys_user_social.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from typing import Union

from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship

from backend.app.models.base import Base, id_key


class UserSocial(Base):
"""用户社交表(OAuth2)"""

__tablename__ = 'sys_user_social'

id: Mapped[id_key] = mapped_column(init=False)
source: Mapped[str] = mapped_column(String(20), comment='第三方用户来源')
open_id: Mapped[str | None] = mapped_column(String(20), default=None, comment='第三方用户的 open id')
uid: Mapped[str | None] = mapped_column(String(20), default=None, comment='第三方用户的 ID')
union_id: Mapped[str | None] = mapped_column(String(20), default=None, comment='第三方用户的 union id')
scope: Mapped[str | None] = mapped_column(String(120), default=None, comment='第三方用户授予的权限')
code: Mapped[str | None] = mapped_column(String(50), default=None, comment='用户的授权 code')
# 用户 OAuth2 一对多
user_id: Mapped[int | None] = mapped_column(
ForeignKey('sys_user.id', ondelete='SET NULL'), default=None, comment='用户关联ID'
)
user: Mapped[Union['User', None]] = relationship(init=False, back_populates='socials') # noqa: F821
2 changes: 1 addition & 1 deletion backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

class AuthSchemaBase(SchemaBase):
username: str
password: str
password: str | None


class AuthLoginParam(AuthSchemaBase):
Expand Down
21 changes: 21 additions & 0 deletions backend/app/schemas/user_social.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from backend.app.common.enums import UserSocialType
from backend.app.schemas.base import SchemaBase


class UserSocialSchemaBase(SchemaBase):
source: UserSocialType
open_id: str | None = None
uid: str | None = None
union_id: str | None = None
scope: str | None = None
code: str | None = None


class CreateUserSocialParam(UserSocialSchemaBase):
user_id: int


class UpdateUserSocialParam(SchemaBase):
pass
Loading
Loading