Skip to content

Commit

Permalink
Add token related interfaces (#495)
Browse files Browse the repository at this point in the history
  • Loading branch information
wu-clan authored Jan 23, 2025
1 parent 7553ccf commit 3b65679
Show file tree
Hide file tree
Showing 14 changed files with 306 additions and 116 deletions.
13 changes: 1 addition & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,6 @@ pattern, use templates to transform it to your heart's content!
| data access | dao / mapper | crud |
| model | model / entity | model |

## Online Demo

You can view some of the preview screenshots
in [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui)

For the demo entrance, please refer
to [Official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)

> tester: test / 123456
>
> super: admin / 123456
## Features

- [x] Design with FastAPI PEP 593 Annotated Parameters
Expand All @@ -81,6 +69,7 @@ to [Official documentation](https://fastapi-practices.github.io/fastapi_best_arc
- [x] Menu management: Configuration of system menus, user menus, button permission labels
- [x] Role management: assignment of role menu privileges, assignment of role routing privileges
- [x] Dictionary management: maintenance of commonly used fixed data or parameters within the system
- [x] Token management:System user online status detection, supports kicking user offline
- [x] Code generation: back-end code is automatically generated, supporting preview, write and download.
- [x] Operation log: logging and querying of normal and abnormal system operations.
- [x] Login authentication: graphical captcha backend authentication login
Expand Down
11 changes: 1 addition & 10 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,6 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
| 数据访问 | dao / mapper | crud |
| 模型 | model / entity | model |

## 在线预览

你可以在 [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui) 中查看部分预览截图

演示入口请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)

> 测试员:tester / 123456
>
> 管理员:admin / 123456
## 特征

- [x] 使用 FastAPI PEP 593 Annotated 参数设计
Expand All @@ -75,6 +65,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
- [x] 菜单管理:配置系统菜单,用户菜单,按钮权限标识
- [x] 角色管理:角色菜单权限分配,角色路由权限分配
- [x] 字典管理:维护系统内部常用固定数据或参数
- [x] 令牌管理:系统用户在线状态检测,支持踢人下线
- [x] 代码生成:后端代码自动生成,支持预览,写入及下载
- [x] 操作日志:系统正常和异常操作的日志记录与查询
- [x] 登录认证:图形验证码后台认证登录
Expand Down
2 changes: 2 additions & 0 deletions backend/app/admin/api/v1/sys/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from backend.app.admin.api.v1.sys.menu import router as menu_router
from backend.app.admin.api.v1.sys.notice import router as notice_router
from backend.app.admin.api.v1.sys.role import router as role_router
from backend.app.admin.api.v1.sys.token import router as token_router
from backend.app.admin.api.v1.sys.user import router as user_router

router = APIRouter(prefix='/sys')
Expand All @@ -27,3 +28,4 @@
router.include_router(user_router, prefix='/users', tags=['系统用户'])
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据权限规则'])
router.include_router(notice_router, prefix='/notices', tags=['系统通知公告'])
router.include_router(token_router, prefix='/tokens', tags=['系统令牌'])
84 changes: 84 additions & 0 deletions backend/app/admin/api/v1/sys/token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json

from typing import Annotated

from fastapi import APIRouter, Depends, Path, Query, Request

from backend.app.admin.schema.token import GetTokenDetail, KickOutToken
from backend.common.enums import StatusType
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
from backend.common.security.jwt import DependsJwtAuth, jwt_decode, superuser_verify
from backend.common.security.permission import RequestPermission
from backend.common.security.rbac import DependsRBAC
from backend.core.conf import settings
from backend.database.redis import redis_client

router = APIRouter()


@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth])
async def get_tokens(username: Annotated[str | None, Query()] = None) -> ResponseSchemaModel[list[GetTokenDetail]]:
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
token_online = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
data = []
for key in token_keys:
token = await redis_client.get(key)
token_payload = jwt_decode(token)
session_uuid = token_payload.session_uuid
token_detail = GetTokenDetail(
id=token_payload.id,
session_uuid=session_uuid,
username='未知',
nickname='未知',
ip='未知',
os='未知',
browser='未知',
device='未知',
status=StatusType.disable if session_uuid not in token_online else StatusType.enable,
last_login_time='未知',
expire_time=token_payload.expire_time,
)
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}')
if extra_info:

def append_token_detail():
data.append(
token_detail.model_copy(
update={
'username': extra_info.get('username'),
'nickname': extra_info.get('nickname'),
'ip': extra_info.get('ip'),
'os': extra_info.get('os'),
'browser': extra_info.get('browser'),
'device': extra_info.get('device'),
'last_login_time': extra_info.get('last_login_time'),
}
)
)

extra_info = json.loads(extra_info)
if extra_info.get('login_type') != 'swagger':
if username:
if username == extra_info.get('username'):
append_token_detail()
else:
append_token_detail()
else:
data.append(token_detail)
return response_base.success(data=data)


@router.delete(
'/{pk}',
summary='踢下线',
dependencies=[
Depends(RequestPermission('sys:token:kick')),
DependsRBAC,
],
)
async def kick_out(request: Request, pk: Annotated[int, Path(...)], session_uuid: KickOutToken) -> ResponseModel:
superuser_verify(request)
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{pk}:{session_uuid}')
return response_base.success()
21 changes: 20 additions & 1 deletion backend/app/admin/schema/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import datetime

from backend.app.admin.schema.user import GetUserInfoNoRelationDetail
from backend.common.enums import StatusType
from backend.common.schema import SchemaBase


Expand All @@ -14,8 +15,8 @@ class GetSwaggerToken(SchemaBase):

class AccessTokenBase(SchemaBase):
access_token: str
access_token_type: str = 'Bearer'
access_token_expire_time: datetime
session_uuid: str


class GetNewToken(AccessTokenBase):
Expand All @@ -24,3 +25,21 @@ class GetNewToken(AccessTokenBase):

class GetLoginToken(AccessTokenBase):
user: GetUserInfoNoRelationDetail


class KickOutToken(SchemaBase):
session_uuid: str


class GetTokenDetail(SchemaBase):
id: int
session_uuid: str
username: str
nickname: str
ip: str
os: str
browser: str
device: str
status: StatusType
last_login_time: str
expire_time: datetime
80 changes: 53 additions & 27 deletions backend/app/admin/service/auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,13 @@ async def user_verify(db: AsyncSession, username: str, password: str) -> User:
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]:
async with async_db_session.begin() as db:
user = await self.user_verify(db, obj.username, obj.password)
user_id = user.id
a_token = await create_access_token(str(user_id), user.is_multi_login)
await user_dao.update_login_time(db, obj.username)
a_token = await create_access_token(
str(user.id),
user.is_multi_login,
# extra info
login_type='swagger',
)
return a_token.access_token, user

async def login(
Expand All @@ -61,9 +65,29 @@ async def login(
raise errors.AuthorizationError(msg='验证码失效,请重新获取')
if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
user_id = user.id
a_token = await create_access_token(str(user_id), user.is_multi_login)
r_token = await create_refresh_token(str(user_id), user.is_multi_login)
await redis_client.delete(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
await user_dao.update_login_time(db, obj.username)
await db.refresh(user)
a_token = await create_access_token(
str(user.id),
user.is_multi_login,
# extra info
username=user.username,
nickname=user.nickname,
last_login_time=timezone.t_str(user.last_login_time),
ip=request.state.ip,
os=request.state.os,
browser=request.state.browser,
device=request.state.device,
)
r_token = await create_refresh_token(str(user.id), user.is_multi_login)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=r_token.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.f_utc(r_token.refresh_token_expire_time),
httponly=True,
)
except errors.NotFoundError as e:
log.error('登陆错误: 用户名不存在')
raise errors.NotFoundError(msg=e.msg)
Expand Down Expand Up @@ -99,19 +123,10 @@ async def login(
msg='登录成功',
),
)
await redis_client.delete(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
await user_dao.update_login_time(db, obj.username)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=r_token.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.f_utc(r_token.refresh_token_expire_time),
httponly=True,
)
await db.refresh(user)
data = GetLoginToken(
access_token=a_token.access_token,
access_token_expire_time=a_token.access_token_expire_time,
session_uuid=a_token.session_uuid,
user=user, # type: ignore
)
return data
Expand All @@ -122,23 +137,31 @@ async def new_token(*, request: Request, response: Response) -> GetNewToken:
if not refresh_token:
raise errors.TokenError(msg='Refresh Token 丢失,请重新登录')
try:
user_id = jwt_decode(refresh_token)
user_id = jwt_decode(refresh_token).id
except Exception:
raise errors.TokenError(msg='Refresh Token 无效')
if request.user.id != user_id:
raise errors.TokenError(msg='Refresh Token 无效')
async with async_db_session() as db:
token = get_token(request)
user = await user_dao.get(db, user_id)
if not user:
raise errors.NotFoundError(msg='用户名或密码有误')
elif not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
current_token = get_token(request)
new_token = await create_new_token(
sub=str(user.id),
token=current_token,
user_id=str(user.id),
token=token,
refresh_token=refresh_token,
multi_login=user.is_multi_login,
# extra info
username=user.username,
nickname=user.nickname,
last_login_time=timezone.t_str(user.last_login_time),
ip=request.state.ip,
os=request.state.os,
browser=request.state.browser,
device_type=request.state.device,
)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
Expand All @@ -150,25 +173,28 @@ async def new_token(*, request: Request, response: Response) -> GetNewToken:
data = GetNewToken(
access_token=new_token.new_access_token,
access_token_expire_time=new_token.new_access_token_expire_time,
session_uuid=new_token.session_uuid,
)
return data

@staticmethod
async def logout(*, request: Request, response: Response) -> None:
token = get_token(request)
token_payload = jwt_decode(token)
user_id = token_payload.id
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
if request.user.is_multi_login:
key = f'{settings.TOKEN_REDIS_PREFIX}:{request.user.id}:{token}'
await redis_client.delete(key)
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}')
if refresh_token:
key = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{request.user.id}:{refresh_token}'
await redis_client.delete(key)
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
else:
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{request.user.id}:'
await redis_client.delete_prefix(key_prefix)
key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{request.user.id}:'
await redis_client.delete_prefix(key_prefix)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:',
]
for prefix in key_prefix:
await redis_client.delete_prefix(prefix)


auth_service: AuthService = AuthService()
Loading

0 comments on commit 3b65679

Please sign in to comment.