Skip to content

Commit cf9e5dc

Browse files
authored
Update user and login security configs (#922)
* Update user and login security configs * Optimize some code definitions * Update config comments * Update the captcha check * Update the config plugin sql scripts * Add user password history model to init * Fix some logic errors * Add last_password_changed_time to user sql * Fix user update password * Fix the dynamic config check * Update the user sql style
1 parent 2c0acb1 commit cf9e5dc

33 files changed

+706
-246
lines changed

backend/app/admin/api/v1/auth/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ async def login_swagger(
2020
db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
2121
) -> GetSwaggerToken:
2222
token, user = await auth_service.swagger_login(db=db, obj=obj)
23-
return GetSwaggerToken(access_token=token, user=user)
23+
return GetSwaggerToken(access_token=token, user=user) # type: ignore
2424

2525

2626
@router.post(
Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from uuid import uuid4
1+
import uuid
22

33
from fast_captcha import img_captcha
44
from fastapi import APIRouter, Depends
@@ -8,7 +8,9 @@
88
from backend.app.admin.schema.captcha import GetCaptchaDetail
99
from backend.common.response.response_schema import ResponseSchemaModel, response_base
1010
from backend.core.conf import settings
11+
from backend.database.db import CurrentSession
1112
from backend.database.redis import redis_client
13+
from backend.utils.dynamic_config import load_login_config
1214

1315
router = APIRouter()
1416

@@ -18,17 +20,19 @@
1820
summary='获取登录验证码',
1921
dependencies=[Depends(RateLimiter(times=5, seconds=10))],
2022
)
21-
async def get_captcha() -> ResponseSchemaModel[GetCaptchaDetail]:
22-
"""
23-
此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗
24-
"""
25-
img_type: str = 'base64'
26-
img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
27-
uuid = str(uuid4())
23+
async def get_captcha(db: CurrentSession) -> ResponseSchemaModel[GetCaptchaDetail]:
24+
await load_login_config(db)
25+
img, code = await run_in_threadpool(img_captcha, img_byte='base64')
26+
captcha_uuid = str(uuid.uuid4())
2827
await redis_client.set(
29-
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}',
28+
f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{captcha_uuid}',
3029
code,
31-
ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
30+
ex=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
31+
)
32+
data = GetCaptchaDetail(
33+
is_enabled=settings.LOGIN_CAPTCHA_ENABLED,
34+
expire_seconds=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
35+
uuid=captcha_uuid,
36+
image=img,
3237
)
33-
data = GetCaptchaDetail(uuid=uuid, img_type=img_type, image=img)
3438
return response_base.success(data=data)

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,7 @@ async def update_user_permission(
102102
async def update_user_password(
103103
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
104104
) -> ResponseModel:
105-
count = await user_service.update_password(
106-
db=db, user_id=request.user.id, hash_password=request.user.password, obj=obj
107-
)
105+
count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
108106
if count > 0:
109107
return response_base.success()
110108
return response_base.fail()

backend/app/admin/crud/crud_user.py

Lines changed: 77 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
AddUserRoleParam,
2525
UpdateUserParam,
2626
)
27-
from backend.common.security.jwt import get_hash_password
27+
from backend.app.admin.utils.password_security import get_hash_password
2828
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
2929
from backend.utils.serializers import select_join_serialize
3030
from backend.utils.timezone import timezone
@@ -63,15 +63,47 @@ async def get_by_nickname(self, db: AsyncSession, nickname: str) -> User | None:
6363
"""
6464
return await self.select_model_by_column(db, nickname=nickname)
6565

66-
async def update_login_time(self, db: AsyncSession, username: str) -> int:
66+
async def check_email(self, db: AsyncSession, email: str) -> User | None:
6767
"""
68-
更新用户最后登录时间
68+
检查邮箱是否已被绑定
6969
7070
:param db: 数据库会话
71+
:param email: 电子邮箱
72+
:return:
73+
"""
74+
return await self.select_model_by_column(db, email=email)
75+
76+
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
77+
"""
78+
获取用户列表查询表达式
79+
80+
:param dept: 部门 ID
7181
:param username: 用户名
82+
:param phone: 电话号码
83+
:param status: 用户状态
7284
:return:
7385
"""
74-
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
86+
filters = {}
87+
88+
if dept:
89+
filters['dept_id'] = dept
90+
if username:
91+
filters['username__like'] = f'%{username}%'
92+
if phone:
93+
filters['phone__like'] = f'%{phone}%'
94+
if status is not None:
95+
filters['status'] = status
96+
97+
return await self.select_order(
98+
'id',
99+
'desc',
100+
join_conditions=[
101+
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
102+
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
103+
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
104+
],
105+
**filters,
106+
)
75107

76108
async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
77109
"""
@@ -119,90 +151,85 @@ async def add_by_oauth2(self, db: AsyncSession, obj: AddOAuth2UserParam) -> None
119151
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
120152
await db.execute(user_role_stmt)
121153

122-
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
154+
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
123155
"""
124156
更新用户信息
125157
126158
:param db: 数据库会话
127-
:param input_user: 用户 ID
159+
:param user_id: 用户 ID
128160
:param obj: 更新用户参数
129161
:return:
130162
"""
131163
role_ids = obj.roles
132164
del obj.roles
133165

134-
count = await self.update_model(db, input_user.id, obj)
166+
count = await self.update_model(db, user_id, obj)
135167

136168
role_stmt = select(Role).where(Role.id.in_(role_ids))
137169
result = await db.execute(role_stmt)
138170
roles = result.scalars().all()
139171

140-
user_role_stmt = delete(user_role).where(user_role.c.user_id == input_user.id)
172+
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
141173
await db.execute(user_role_stmt)
142174

143-
user_role_data = [AddUserRoleParam(user_id=input_user.id, role_id=role.id).model_dump() for role in roles]
175+
user_role_data = [AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump() for role in roles]
144176
user_role_stmt = insert(user_role)
145177
await db.execute(user_role_stmt, user_role_data)
146178

147179
return count
148180

149-
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
181+
async def update_login_time(self, db: AsyncSession, username: str) -> int:
150182
"""
151-
更新用户昵称
183+
更新用户上次登录时间
152184
153185
:param db: 数据库会话
154-
:param user_id: 用户 ID
155-
:param nickname: 用户昵称
186+
:param username: 用户名
156187
:return:
157188
"""
158-
return await self.update_model(db, user_id, {'nickname': nickname})
189+
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
159190

160-
async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
191+
async def update_password_changed_time(self, db: AsyncSession, user_id: int) -> int:
161192
"""
162-
更新用户头像
193+
更新用户上次密码变更时间
163194
164195
:param db: 数据库会话
165196
:param user_id: 用户 ID
166-
:param avatar: 头像地址
167197
:return:
168198
"""
169-
return await self.update_model(db, user_id, {'avatar': avatar})
199+
return await self.update_model(db, user_id, {'last_password_changed_time': timezone.now()})
170200

171-
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
201+
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
172202
"""
173-
更新用户邮箱
203+
更新用户昵称
174204
175205
:param db: 数据库会话
176206
:param user_id: 用户 ID
177-
:param email: 邮箱
207+
:param nickname: 用户昵称
178208
:return:
179209
"""
180-
return await self.update_model(db, user_id, {'email': email})
210+
return await self.update_model(db, user_id, {'nickname': nickname})
181211

182-
async def delete(self, db: AsyncSession, user_id: int) -> int:
212+
async def update_avatar(self, db: AsyncSession, user_id: int, avatar: str) -> int:
183213
"""
184-
删除用户
214+
更新用户头像
185215
186216
:param db: 数据库会话
187217
:param user_id: 用户 ID
218+
:param avatar: 头像地址
188219
:return:
189220
"""
190-
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
191-
await db.execute(user_role_stmt)
192-
193-
await user_social_dao.delete_by_user_id(db, user_id)
194-
195-
return await self.delete_model(db, user_id)
221+
return await self.update_model(db, user_id, {'avatar': avatar})
196222

197-
async def check_email(self, db: AsyncSession, email: str) -> User | None:
223+
async def update_email(self, db: AsyncSession, user_id: int, email: str) -> int:
198224
"""
199-
检查邮箱是否已被绑定
225+
更新用户邮箱
200226
201227
:param db: 数据库会话
202-
:param email: 电子邮箱
228+
:param user_id: 用户 ID
229+
:param email: 邮箱
203230
:return:
204231
"""
205-
return await self.select_model_by_column(db, email=email)
232+
return await self.update_model(db, user_id, {'email': email})
206233

207234
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
208235
"""
@@ -215,39 +242,7 @@ async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
215242
"""
216243
salt = bcrypt.gensalt()
217244
new_pwd = get_hash_password(password, salt)
218-
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt})
219-
220-
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
221-
"""
222-
获取用户列表查询表达式
223-
224-
:param dept: 部门 ID
225-
:param username: 用户名
226-
:param phone: 电话号码
227-
:param status: 用户状态
228-
:return:
229-
"""
230-
filters = {}
231-
232-
if dept:
233-
filters['dept_id'] = dept
234-
if username:
235-
filters['username__like'] = f'%{username}%'
236-
if phone:
237-
filters['phone__like'] = f'%{phone}%'
238-
if status is not None:
239-
filters['status'] = status
240-
241-
return await self.select_order(
242-
'id',
243-
'desc',
244-
join_conditions=[
245-
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
246-
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
247-
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
248-
],
249-
**filters,
250-
)
245+
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}, flush=True)
251246

252247
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
253248
"""
@@ -293,6 +288,21 @@ async def set_multi_login(self, db: AsyncSession, user_id: int, *, multi_login:
293288
"""
294289
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
295290

291+
async def delete(self, db: AsyncSession, user_id: int) -> int:
292+
"""
293+
删除用户
294+
295+
:param db: 数据库会话
296+
:param user_id: 用户 ID
297+
:return:
298+
"""
299+
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
300+
await db.execute(user_role_stmt)
301+
302+
await user_social_dao.delete_by_user_id(db, user_id)
303+
304+
return await self.delete_model(db, user_id)
305+
296306
async def get_join(
297307
self,
298308
db: AsyncSession,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from collections.abc import Sequence
2+
3+
from sqlalchemy.ext.asyncio import AsyncSession
4+
from sqlalchemy_crud_plus import CRUDPlus
5+
6+
from backend.app.admin.model.user_password_history import UserPasswordHistory
7+
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
8+
9+
10+
class CRUDUserPasswordHistory(CRUDPlus[UserPasswordHistory]):
11+
"""用户密码历史记录数据库操作类"""
12+
13+
async def create(self, db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
14+
"""
15+
创建密码历史记录
16+
17+
:param db: 数据库会话
18+
:param obj: 创建密码历史记录参数
19+
:return:
20+
"""
21+
await self.create_model(db, obj)
22+
23+
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserPasswordHistory]:
24+
"""
25+
获取用户的密码历史记录
26+
27+
:param db: 数据库会话
28+
:param user_id: 用户 ID
29+
:return:
30+
"""
31+
return await self.select_models_order(db, 'id', 'desc', self.model.user_id == user_id)
32+
33+
34+
user_password_history_dao: CRUDUserPasswordHistory = CRUDUserPasswordHistory(UserPasswordHistory)

backend/app/admin/model/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
from backend.app.admin.model.opera_log import OperaLog as OperaLog
1111
from backend.app.admin.model.role import Role as Role
1212
from backend.app.admin.model.user import User as User
13+
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory

backend/app/admin/model/user.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ class User(Base):
2929
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
3030
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
3131
last_login_time: Mapped[datetime | None] = mapped_column(
32-
TimeZone, init=False, onupdate=timezone.now, comment='上次登录'
32+
TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
33+
)
34+
last_password_changed_time: Mapped[datetime | None] = mapped_column(
35+
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
3336
)
3437

3538
# 逻辑外键
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from datetime import datetime
2+
3+
import sqlalchemy as sa
4+
5+
from sqlalchemy.orm import Mapped, mapped_column
6+
7+
from backend.common.model import DataClassBase, TimeZone, id_key
8+
from backend.utils.timezone import timezone
9+
10+
11+
class UserPasswordHistory(DataClassBase):
12+
"""用户密码历史记录表"""
13+
14+
__tablename__ = 'sys_user_password_history'
15+
16+
id: Mapped[id_key] = mapped_column(init=False)
17+
user_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='用户 ID')
18+
password: Mapped[str] = mapped_column(sa.String(256), comment='历史密码')
19+
created_time: Mapped[datetime] = mapped_column(
20+
TimeZone,
21+
init=False,
22+
default_factory=timezone.now,
23+
comment='创建时间',
24+
)

backend/app/admin/schema/captcha.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
class GetCaptchaDetail(SchemaBase):
77
"""验证码详情"""
88

9+
is_enabled: bool = Field(description='是否启用')
10+
expire_seconds: int = Field(description='过期秒数')
911
uuid: str = Field(description='图片唯一标识')
10-
img_type: str = Field(description='图片类型')
1112
image: str = Field(description='图片内容')

backend/app/admin/schema/token.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class GetNewToken(AccessTokenBase):
3030
class GetLoginToken(AccessTokenBase):
3131
"""获取登录令牌"""
3232

33+
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
3334
user: GetUserInfoDetail = Field(description='用户信息')
3435

3536

0 commit comments

Comments
 (0)