Skip to content

Commit 3b65679

Browse files
authored
Add token related interfaces (#495)
1 parent 7553ccf commit 3b65679

File tree

14 files changed

+306
-116
lines changed

14 files changed

+306
-116
lines changed

README.md

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,6 @@ pattern, use templates to transform it to your heart's content!
4747
| data access | dao / mapper | crud |
4848
| model | model / entity | model |
4949

50-
## Online Demo
51-
52-
You can view some of the preview screenshots
53-
in [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui)
54-
55-
For the demo entrance, please refer
56-
to [Official documentation](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
57-
58-
> tester: test / 123456
59-
>
60-
> super: admin / 123456
61-
6250
## Features
6351

6452
- [x] Design with FastAPI PEP 593 Annotated Parameters
@@ -81,6 +69,7 @@ to [Official documentation](https://fastapi-practices.github.io/fastapi_best_arc
8169
- [x] Menu management: Configuration of system menus, user menus, button permission labels
8270
- [x] Role management: assignment of role menu privileges, assignment of role routing privileges
8371
- [x] Dictionary management: maintenance of commonly used fixed data or parameters within the system
72+
- [x] Token management:System user online status detection, supports kicking user offline
8473
- [x] Code generation: back-end code is automatically generated, supporting preview, write and download.
8574
- [x] Operation log: logging and querying of normal and abnormal system operations.
8675
- [x] Login authentication: graphical captcha backend authentication login

README.zh-CN.md

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,6 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
4343
| 数据访问 | dao / mapper | crud |
4444
| 模型 | model / entity | model |
4545

46-
## 在线预览
47-
48-
你可以在 [fastapi_best_architecture_ui](https://github.com/fastapi-practices/fastapi_best_architecture_ui) 中查看部分预览截图
49-
50-
演示入口请查看 [官方文档](https://fastapi-practices.github.io/fastapi_best_architecture_docs/)
51-
52-
> 测试员:tester / 123456
53-
>
54-
> 管理员:admin / 123456
55-
5646
## 特征
5747

5848
- [x] 使用 FastAPI PEP 593 Annotated 参数设计
@@ -75,6 +65,7 @@ mvc 架构作为常规设计模式,在 python web 中也很常见,但是三
7565
- [x] 菜单管理:配置系统菜单,用户菜单,按钮权限标识
7666
- [x] 角色管理:角色菜单权限分配,角色路由权限分配
7767
- [x] 字典管理:维护系统内部常用固定数据或参数
68+
- [x] 令牌管理:系统用户在线状态检测,支持踢人下线
7869
- [x] 代码生成:后端代码自动生成,支持预览,写入及下载
7970
- [x] 操作日志:系统正常和异常操作的日志记录与查询
8071
- [x] 登录认证:图形验证码后台认证登录

backend/app/admin/api/v1/sys/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from backend.app.admin.api.v1.sys.menu import router as menu_router
1313
from backend.app.admin.api.v1.sys.notice import router as notice_router
1414
from backend.app.admin.api.v1.sys.role import router as role_router
15+
from backend.app.admin.api.v1.sys.token import router as token_router
1516
from backend.app.admin.api.v1.sys.user import router as user_router
1617

1718
router = APIRouter(prefix='/sys')
@@ -27,3 +28,4 @@
2728
router.include_router(user_router, prefix='/users', tags=['系统用户'])
2829
router.include_router(data_rule_router, prefix='/data-rules', tags=['系统数据权限规则'])
2930
router.include_router(notice_router, prefix='/notices', tags=['系统通知公告'])
31+
router.include_router(token_router, prefix='/tokens', tags=['系统令牌'])

backend/app/admin/api/v1/sys/token.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
import json
4+
5+
from typing import Annotated
6+
7+
from fastapi import APIRouter, Depends, Path, Query, Request
8+
9+
from backend.app.admin.schema.token import GetTokenDetail, KickOutToken
10+
from backend.common.enums import StatusType
11+
from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base
12+
from backend.common.security.jwt import DependsJwtAuth, jwt_decode, superuser_verify
13+
from backend.common.security.permission import RequestPermission
14+
from backend.common.security.rbac import DependsRBAC
15+
from backend.core.conf import settings
16+
from backend.database.redis import redis_client
17+
18+
router = APIRouter()
19+
20+
21+
@router.get('', summary='获取令牌列表', dependencies=[DependsJwtAuth])
22+
async def get_tokens(username: Annotated[str | None, Query()] = None) -> ResponseSchemaModel[list[GetTokenDetail]]:
23+
token_keys = await redis_client.keys(f'{settings.TOKEN_REDIS_PREFIX}:*')
24+
token_online = await redis_client.smembers(settings.TOKEN_ONLINE_REDIS_PREFIX)
25+
data = []
26+
for key in token_keys:
27+
token = await redis_client.get(key)
28+
token_payload = jwt_decode(token)
29+
session_uuid = token_payload.session_uuid
30+
token_detail = GetTokenDetail(
31+
id=token_payload.id,
32+
session_uuid=session_uuid,
33+
username='未知',
34+
nickname='未知',
35+
ip='未知',
36+
os='未知',
37+
browser='未知',
38+
device='未知',
39+
status=StatusType.disable if session_uuid not in token_online else StatusType.enable,
40+
last_login_time='未知',
41+
expire_time=token_payload.expire_time,
42+
)
43+
extra_info = await redis_client.get(f'{settings.TOKEN_EXTRA_INFO_REDIS_PREFIX}:{session_uuid}')
44+
if extra_info:
45+
46+
def append_token_detail():
47+
data.append(
48+
token_detail.model_copy(
49+
update={
50+
'username': extra_info.get('username'),
51+
'nickname': extra_info.get('nickname'),
52+
'ip': extra_info.get('ip'),
53+
'os': extra_info.get('os'),
54+
'browser': extra_info.get('browser'),
55+
'device': extra_info.get('device'),
56+
'last_login_time': extra_info.get('last_login_time'),
57+
}
58+
)
59+
)
60+
61+
extra_info = json.loads(extra_info)
62+
if extra_info.get('login_type') != 'swagger':
63+
if username:
64+
if username == extra_info.get('username'):
65+
append_token_detail()
66+
else:
67+
append_token_detail()
68+
else:
69+
data.append(token_detail)
70+
return response_base.success(data=data)
71+
72+
73+
@router.delete(
74+
'/{pk}',
75+
summary='踢下线',
76+
dependencies=[
77+
Depends(RequestPermission('sys:token:kick')),
78+
DependsRBAC,
79+
],
80+
)
81+
async def kick_out(request: Request, pk: Annotated[int, Path(...)], session_uuid: KickOutToken) -> ResponseModel:
82+
superuser_verify(request)
83+
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{pk}:{session_uuid}')
84+
return response_base.success()

backend/app/admin/schema/token.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime
44

55
from backend.app.admin.schema.user import GetUserInfoNoRelationDetail
6+
from backend.common.enums import StatusType
67
from backend.common.schema import SchemaBase
78

89

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

1516
class AccessTokenBase(SchemaBase):
1617
access_token: str
17-
access_token_type: str = 'Bearer'
1818
access_token_expire_time: datetime
19+
session_uuid: str
1920

2021

2122
class GetNewToken(AccessTokenBase):
@@ -24,3 +25,21 @@ class GetNewToken(AccessTokenBase):
2425

2526
class GetLoginToken(AccessTokenBase):
2627
user: GetUserInfoNoRelationDetail
28+
29+
30+
class KickOutToken(SchemaBase):
31+
session_uuid: str
32+
33+
34+
class GetTokenDetail(SchemaBase):
35+
id: int
36+
session_uuid: str
37+
username: str
38+
nickname: str
39+
ip: str
40+
os: str
41+
browser: str
42+
device: str
43+
status: StatusType
44+
last_login_time: str
45+
expire_time: datetime

backend/app/admin/service/auth_service.py

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async def user_verify(db: AsyncSession, username: str, password: str) -> User:
4444
async def swagger_login(self, *, obj: HTTPBasicCredentials) -> tuple[str, User]:
4545
async with async_db_session.begin() as db:
4646
user = await self.user_verify(db, obj.username, obj.password)
47-
user_id = user.id
48-
a_token = await create_access_token(str(user_id), user.is_multi_login)
4947
await user_dao.update_login_time(db, obj.username)
48+
a_token = await create_access_token(
49+
str(user.id),
50+
user.is_multi_login,
51+
# extra info
52+
login_type='swagger',
53+
)
5054
return a_token.access_token, user
5155

5256
async def login(
@@ -61,9 +65,29 @@ async def login(
6165
raise errors.AuthorizationError(msg='验证码失效,请重新获取')
6266
if captcha_code.lower() != obj.captcha.lower():
6367
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
64-
user_id = user.id
65-
a_token = await create_access_token(str(user_id), user.is_multi_login)
66-
r_token = await create_refresh_token(str(user_id), user.is_multi_login)
68+
await redis_client.delete(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
69+
await user_dao.update_login_time(db, obj.username)
70+
await db.refresh(user)
71+
a_token = await create_access_token(
72+
str(user.id),
73+
user.is_multi_login,
74+
# extra info
75+
username=user.username,
76+
nickname=user.nickname,
77+
last_login_time=timezone.t_str(user.last_login_time),
78+
ip=request.state.ip,
79+
os=request.state.os,
80+
browser=request.state.browser,
81+
device=request.state.device,
82+
)
83+
r_token = await create_refresh_token(str(user.id), user.is_multi_login)
84+
response.set_cookie(
85+
key=settings.COOKIE_REFRESH_TOKEN_KEY,
86+
value=r_token.refresh_token,
87+
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
88+
expires=timezone.f_utc(r_token.refresh_token_expire_time),
89+
httponly=True,
90+
)
6791
except errors.NotFoundError as e:
6892
log.error('登陆错误: 用户名不存在')
6993
raise errors.NotFoundError(msg=e.msg)
@@ -99,19 +123,10 @@ async def login(
99123
msg='登录成功',
100124
),
101125
)
102-
await redis_client.delete(f'{admin_settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{request.state.ip}')
103-
await user_dao.update_login_time(db, obj.username)
104-
response.set_cookie(
105-
key=settings.COOKIE_REFRESH_TOKEN_KEY,
106-
value=r_token.refresh_token,
107-
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
108-
expires=timezone.f_utc(r_token.refresh_token_expire_time),
109-
httponly=True,
110-
)
111-
await db.refresh(user)
112126
data = GetLoginToken(
113127
access_token=a_token.access_token,
114128
access_token_expire_time=a_token.access_token_expire_time,
129+
session_uuid=a_token.session_uuid,
115130
user=user, # type: ignore
116131
)
117132
return data
@@ -122,23 +137,31 @@ async def new_token(*, request: Request, response: Response) -> GetNewToken:
122137
if not refresh_token:
123138
raise errors.TokenError(msg='Refresh Token 丢失,请重新登录')
124139
try:
125-
user_id = jwt_decode(refresh_token)
140+
user_id = jwt_decode(refresh_token).id
126141
except Exception:
127142
raise errors.TokenError(msg='Refresh Token 无效')
128143
if request.user.id != user_id:
129144
raise errors.TokenError(msg='Refresh Token 无效')
130145
async with async_db_session() as db:
146+
token = get_token(request)
131147
user = await user_dao.get(db, user_id)
132148
if not user:
133149
raise errors.NotFoundError(msg='用户名或密码有误')
134150
elif not user.status:
135151
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
136-
current_token = get_token(request)
137152
new_token = await create_new_token(
138-
sub=str(user.id),
139-
token=current_token,
153+
user_id=str(user.id),
154+
token=token,
140155
refresh_token=refresh_token,
141156
multi_login=user.is_multi_login,
157+
# extra info
158+
username=user.username,
159+
nickname=user.nickname,
160+
last_login_time=timezone.t_str(user.last_login_time),
161+
ip=request.state.ip,
162+
os=request.state.os,
163+
browser=request.state.browser,
164+
device_type=request.state.device,
142165
)
143166
response.set_cookie(
144167
key=settings.COOKIE_REFRESH_TOKEN_KEY,
@@ -150,25 +173,28 @@ async def new_token(*, request: Request, response: Response) -> GetNewToken:
150173
data = GetNewToken(
151174
access_token=new_token.new_access_token,
152175
access_token_expire_time=new_token.new_access_token_expire_time,
176+
session_uuid=new_token.session_uuid,
153177
)
154178
return data
155179

156180
@staticmethod
157181
async def logout(*, request: Request, response: Response) -> None:
158182
token = get_token(request)
183+
token_payload = jwt_decode(token)
184+
user_id = token_payload.id
159185
refresh_token = request.cookies.get(settings.COOKIE_REFRESH_TOKEN_KEY)
160186
response.delete_cookie(settings.COOKIE_REFRESH_TOKEN_KEY)
161187
if request.user.is_multi_login:
162-
key = f'{settings.TOKEN_REDIS_PREFIX}:{request.user.id}:{token}'
163-
await redis_client.delete(key)
188+
await redis_client.delete(f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:{token_payload.session_uuid}')
164189
if refresh_token:
165-
key = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{request.user.id}:{refresh_token}'
166-
await redis_client.delete(key)
190+
await redis_client.delete(f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:{refresh_token}')
167191
else:
168-
key_prefix = f'{settings.TOKEN_REDIS_PREFIX}:{request.user.id}:'
169-
await redis_client.delete_prefix(key_prefix)
170-
key_prefix = f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{request.user.id}:'
171-
await redis_client.delete_prefix(key_prefix)
192+
key_prefix = [
193+
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}:',
194+
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}:',
195+
]
196+
for prefix in key_prefix:
197+
await redis_client.delete_prefix(prefix)
172198

173199

174200
auth_service: AuthService = AuthService()

0 commit comments

Comments
 (0)