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
This commit is contained in:
Wu Clan
2025-11-16 21:23:36 +08:00
committed by GitHub
parent 2c0acb1103
commit cf9e5dc4f4
33 changed files with 707 additions and 247 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ async def login_swagger(
db: CurrentSessionTransaction, obj: Annotated[HTTPBasicCredentials, Depends()]
) -> GetSwaggerToken:
token, user = await auth_service.swagger_login(db=db, obj=obj)
return GetSwaggerToken(access_token=token, user=user)
return GetSwaggerToken(access_token=token, user=user) # type: ignore
@router.post(
+15 -11
View File
@@ -1,4 +1,4 @@
from uuid import uuid4
import uuid
from fast_captcha import img_captcha
from fastapi import APIRouter, Depends
@@ -8,7 +8,9 @@ from starlette.concurrency import run_in_threadpool
from backend.app.admin.schema.captcha import GetCaptchaDetail
from backend.common.response.response_schema import ResponseSchemaModel, response_base
from backend.core.conf import settings
from backend.database.db import CurrentSession
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config
router = APIRouter()
@@ -18,17 +20,19 @@ router = APIRouter()
summary='获取登录验证码',
dependencies=[Depends(RateLimiter(times=5, seconds=10))],
)
async def get_captcha() -> ResponseSchemaModel[GetCaptchaDetail]:
"""
此接口可能存在性能损耗,尽管是异步接口,但是验证码生成是IO密集型任务,使用线程池尽量减少性能损耗
"""
img_type: str = 'base64'
img, code = await run_in_threadpool(img_captcha, img_byte=img_type)
uuid = str(uuid4())
async def get_captcha(db: CurrentSession) -> ResponseSchemaModel[GetCaptchaDetail]:
await load_login_config(db)
img, code = await run_in_threadpool(img_captcha, img_byte='base64')
captcha_uuid = str(uuid.uuid4())
await redis_client.set(
f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{uuid}',
f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{captcha_uuid}',
code,
ex=settings.CAPTCHA_LOGIN_EXPIRE_SECONDS,
ex=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
)
data = GetCaptchaDetail(
is_enabled=settings.LOGIN_CAPTCHA_ENABLED,
expire_seconds=settings.LOGIN_CAPTCHA_EXPIRE_SECONDS,
uuid=captcha_uuid,
image=img,
)
data = GetCaptchaDetail(uuid=uuid, img_type=img_type, image=img)
return response_base.success(data=data)
+1 -3
View File
@@ -102,9 +102,7 @@ async def update_user_permission(
async def update_user_password(
db: CurrentSessionTransaction, request: Request, obj: ResetPasswordParam
) -> ResponseModel:
count = await user_service.update_password(
db=db, user_id=request.user.id, hash_password=request.user.password, obj=obj
)
count = await user_service.update_password(db=db, user_id=request.user.id, obj=obj)
if count > 0:
return response_base.success()
return response_base.fail()
+78 -68
View File
@@ -24,7 +24,7 @@ from backend.app.admin.schema.user import (
AddUserRoleParam,
UpdateUserParam,
)
from backend.common.security.jwt import get_hash_password
from backend.app.admin.utils.password_security import get_hash_password
from backend.plugin.oauth2.crud.crud_user_social import user_social_dao
from backend.utils.serializers import select_join_serialize
from backend.utils.timezone import timezone
@@ -63,15 +63,47 @@ class CRUDUser(CRUDPlus[User]):
"""
return await self.select_model_by_column(db, nickname=nickname)
async def update_login_time(self, db: AsyncSession, username: str) -> int:
async def check_email(self, db: AsyncSession, email: str) -> User | None:
"""
更新用户最后登录时间
检查邮箱是否已被绑定
:param db: 数据库会话
:param username: 用户名
:param email: 电子邮箱
:return:
"""
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
return await self.select_model_by_column(db, email=email)
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
"""
获取用户列表查询表达式
:param dept: 部门 ID
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
filters = {}
if dept:
filters['dept_id'] = dept
if username:
filters['username__like'] = f'%{username}%'
if phone:
filters['phone__like'] = f'%{phone}%'
if status is not None:
filters['status'] = status
return await self.select_order(
'id',
'desc',
join_conditions=[
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
],
**filters,
)
async def add(self, db: AsyncSession, obj: AddUserParam) -> None:
"""
@@ -119,33 +151,53 @@ class CRUDUser(CRUDPlus[User]):
user_role_stmt = insert(user_role).values(AddUserRoleParam(user_id=new_user.id, role_id=role.id).model_dump())
await db.execute(user_role_stmt)
async def update(self, db: AsyncSession, input_user: User, obj: UpdateUserParam) -> int:
async def update(self, db: AsyncSession, user_id: int, obj: UpdateUserParam) -> int:
"""
更新用户信息
:param db: 数据库会话
:param input_user: 用户 ID
:param user_id: 用户 ID
:param obj: 更新用户参数
:return:
"""
role_ids = obj.roles
del obj.roles
count = await self.update_model(db, input_user.id, obj)
count = await self.update_model(db, user_id, obj)
role_stmt = select(Role).where(Role.id.in_(role_ids))
result = await db.execute(role_stmt)
roles = result.scalars().all()
user_role_stmt = delete(user_role).where(user_role.c.user_id == input_user.id)
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
user_role_data = [AddUserRoleParam(user_id=input_user.id, role_id=role.id).model_dump() for role in roles]
user_role_data = [AddUserRoleParam(user_id=user_id, role_id=role.id).model_dump() for role in roles]
user_role_stmt = insert(user_role)
await db.execute(user_role_stmt, user_role_data)
return count
async def update_login_time(self, db: AsyncSession, username: str) -> int:
"""
更新用户上次登录时间
:param db: 数据库会话
:param username: 用户名
:return:
"""
return await self.update_model_by_column(db, {'last_login_time': timezone.now()}, username=username)
async def update_password_changed_time(self, db: AsyncSession, user_id: int) -> int:
"""
更新用户上次密码变更时间
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.update_model(db, user_id, {'last_password_changed_time': timezone.now()})
async def update_nickname(self, db: AsyncSession, user_id: int, nickname: str) -> int:
"""
更新用户昵称
@@ -179,31 +231,6 @@ class CRUDUser(CRUDPlus[User]):
"""
return await self.update_model(db, user_id, {'email': email})
async def delete(self, db: AsyncSession, user_id: int) -> int:
"""
删除用户
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
await user_social_dao.delete_by_user_id(db, user_id)
return await self.delete_model(db, user_id)
async def check_email(self, db: AsyncSession, email: str) -> User | None:
"""
检查邮箱是否已被绑定
:param db: 数据库会话
:param email: 电子邮箱
:return:
"""
return await self.select_model_by_column(db, email=email)
async def reset_password(self, db: AsyncSession, pk: int, password: str) -> int:
"""
重置用户密码
@@ -215,39 +242,7 @@ class CRUDUser(CRUDPlus[User]):
"""
salt = bcrypt.gensalt()
new_pwd = get_hash_password(password, salt)
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt})
async def get_select(self, dept: int | None, username: str | None, phone: str | None, status: int | None) -> Select:
"""
获取用户列表查询表达式
:param dept: 部门 ID
:param username: 用户名
:param phone: 电话号码
:param status: 用户状态
:return:
"""
filters = {}
if dept:
filters['dept_id'] = dept
if username:
filters['username__like'] = f'%{username}%'
if phone:
filters['phone__like'] = f'%{phone}%'
if status is not None:
filters['status'] = status
return await self.select_order(
'id',
'desc',
join_conditions=[
JoinConfig(model=Dept, join_on=Dept.id == self.model.dept_id, fill_result=True),
JoinConfig(model=user_role, join_on=user_role.c.user_id == self.model.id),
JoinConfig(model=Role, join_on=Role.id == user_role.c.role_id, fill_result=True),
],
**filters,
)
return await self.update_model(db, pk, {'password': new_pwd, 'salt': salt}, flush=True)
async def set_super(self, db: AsyncSession, user_id: int, *, is_super: bool) -> int:
"""
@@ -293,6 +288,21 @@ class CRUDUser(CRUDPlus[User]):
"""
return await self.update_model(db, user_id, {'is_multi_login': multi_login})
async def delete(self, db: AsyncSession, user_id: int) -> int:
"""
删除用户
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
user_role_stmt = delete(user_role).where(user_role.c.user_id == user_id)
await db.execute(user_role_stmt)
await user_social_dao.delete_by_user_id(db, user_id)
return await self.delete_model(db, user_id)
async def get_join(
self,
db: AsyncSession,
@@ -0,0 +1,34 @@
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy_crud_plus import CRUDPlus
from backend.app.admin.model.user_password_history import UserPasswordHistory
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
class CRUDUserPasswordHistory(CRUDPlus[UserPasswordHistory]):
"""用户密码历史记录数据库操作类"""
async def create(self, db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
"""
创建密码历史记录
:param db: 数据库会话
:param obj: 创建密码历史记录参数
:return:
"""
await self.create_model(db, obj)
async def get_by_user_id(self, db: AsyncSession, user_id: int) -> Sequence[UserPasswordHistory]:
"""
获取用户的密码历史记录
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
return await self.select_models_order(db, 'id', 'desc', self.model.user_id == user_id)
user_password_history_dao: CRUDUserPasswordHistory = CRUDUserPasswordHistory(UserPasswordHistory)
+1
View File
@@ -10,3 +10,4 @@ from backend.app.admin.model.menu import Menu as Menu
from backend.app.admin.model.opera_log import OperaLog as OperaLog
from backend.app.admin.model.role import Role as Role
from backend.app.admin.model.user import User as User
from backend.app.admin.model.user_password_history import UserPasswordHistory as UserPasswordHistory
+4 -1
View File
@@ -29,7 +29,10 @@ class User(Base):
is_multi_login: Mapped[bool] = mapped_column(default=False, comment='是否重复登陆(0否 1是)')
join_time: Mapped[datetime] = mapped_column(TimeZone, init=False, default_factory=timezone.now, comment='注册时间')
last_login_time: Mapped[datetime | None] = mapped_column(
TimeZone, init=False, onupdate=timezone.now, comment='上次登录'
TimeZone, init=False, onupdate=timezone.now, comment='上次登录时间'
)
last_password_changed_time: Mapped[datetime | None] = mapped_column(
TimeZone, init=False, default_factory=timezone.now, comment='上次密码变更时间'
)
# 逻辑外键
@@ -0,0 +1,24 @@
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from backend.common.model import DataClassBase, TimeZone, id_key
from backend.utils.timezone import timezone
class UserPasswordHistory(DataClassBase):
"""用户密码历史记录表"""
__tablename__ = 'sys_user_password_history'
id: Mapped[id_key] = mapped_column(init=False)
user_id: Mapped[int] = mapped_column(sa.BigInteger, index=True, comment='用户 ID')
password: Mapped[str] = mapped_column(sa.String(256), comment='历史密码')
created_time: Mapped[datetime] = mapped_column(
TimeZone,
init=False,
default_factory=timezone.now,
comment='创建时间',
)
+2 -1
View File
@@ -6,6 +6,7 @@ from backend.common.schema import SchemaBase
class GetCaptchaDetail(SchemaBase):
"""验证码详情"""
is_enabled: bool = Field(description='是否启用')
expire_seconds: int = Field(description='过期秒数')
uuid: str = Field(description='图片唯一标识')
img_type: str = Field(description='图片类型')
image: str = Field(description='图片内容')
+1
View File
@@ -30,6 +30,7 @@ class GetNewToken(AccessTokenBase):
class GetLoginToken(AccessTokenBase):
"""获取登录令牌"""
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
user: GetUserInfoDetail = Field(description='用户信息')
+2 -2
View File
@@ -20,8 +20,8 @@ class AuthSchemaBase(SchemaBase):
class AuthLoginParam(AuthSchemaBase):
"""用户登录参数"""
uuid: str = Field(description='验证码 UUID')
captcha: str = Field(description='验证码')
captcha_uuid: str | None = Field(None, description='验证码 UUID')
captcha: str | None = Field(None, description='验证码')
class AddUserParam(AuthSchemaBase):
@@ -0,0 +1,14 @@
from pydantic import Field
from backend.common.schema import SchemaBase
class UserPasswordHistoryBase(SchemaBase):
"""用户历史密码记录基础模型"""
user_id: int = Field(description='用户 ID')
password: str = Field(description='历史密码')
class CreateUserPasswordHistoryParam(UserPasswordHistoryBase):
"""创建用户历史密码记录"""
+39 -27
View File
@@ -9,6 +9,8 @@ from backend.app.admin.model import User
from backend.app.admin.schema.token import GetLoginToken, GetNewToken
from backend.app.admin.schema.user import AuthLoginParam
from backend.app.admin.service.login_log_service import login_log_service
from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify
from backend.common.context import ctx
from backend.common.enums import LoginLogStatusType
from backend.common.exception import errors
@@ -21,11 +23,11 @@ from backend.common.security.jwt import (
create_refresh_token,
get_token,
jwt_decode,
password_verify,
)
from backend.core.conf import settings
from backend.database.db import uuid4_str
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_login_config
from backend.utils.timezone import timezone
@@ -33,7 +35,7 @@ class AuthService:
"""认证服务类"""
@staticmethod
async def user_verify(db: AsyncSession, username: str, password: str) -> User:
async def user_verify(db: AsyncSession, username: str, password: str) -> tuple[User, int | None]:
"""
验证用户名和密码
@@ -46,15 +48,19 @@ class AuthService:
if not user:
raise errors.NotFoundError(msg='用户名或密码有误')
if user.password is None:
raise errors.AuthorizationError(msg='用户名或密码有误')
if not password_verify(password, user.password):
await password_security_service.check_status(user.id, user.status)
if user.password is None or not password_verify(password, user.password):
await password_security_service.handle_login_failure(db, user.id)
raise errors.AuthorizationError(msg='用户名或密码有误')
if not user.status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
days_remaining = await password_security_service.check_password_expiry_status(
db, user.last_password_changed_time
)
return user
await password_security_service.handle_login_success(user.id)
return user, days_remaining
async def swagger_login(self, *, db: AsyncSession, obj: HTTPBasicCredentials) -> tuple[str, User]:
"""
@@ -64,15 +70,15 @@ class AuthService:
:param obj: 登录凭证
:return:
"""
user = await self.user_verify(db, obj.username, obj.password)
user, _ = await self.user_verify(db, obj.username, obj.password)
await user_dao.update_login_time(db, obj.username)
access_token = await create_access_token(
access_token_data = await create_access_token(
user.id,
multi_login=user.is_multi_login,
# extra info
swagger=True,
)
return access_token.access_token, user
return access_token_data.access_token, user
async def login(
self,
@@ -86,7 +92,6 @@ class AuthService:
用户登录
:param db: 数据库会话
:param request: 请求对象
:param response: 响应对象
:param obj: 登录参数
:param background_tasks: 后台任务
@@ -94,16 +99,22 @@ class AuthService:
"""
user = None
try:
user = await self.user_verify(db, obj.username, obj.password)
captcha_code = await redis_client.get(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
if not captcha_code:
raise errors.RequestError(msg=t('error.captcha.expired'))
if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{obj.uuid}')
user, days_remaining = await self.user_verify(db, obj.username, obj.password)
await load_login_config(db)
if settings.LOGIN_CAPTCHA_ENABLED:
if not obj.captcha_uuid or not obj.captcha:
raise errors.RequestError(msg=t('error.captcha.invalid'))
captcha_code = await redis_client.get(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.captcha_uuid}')
if not captcha_code:
raise errors.RequestError(msg=t('error.captcha.expired'))
if captcha_code.lower() != obj.captcha.lower():
raise errors.CustomError(error=CustomErrorCode.CAPTCHA_ERROR)
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{obj.captcha_uuid}')
await user_dao.update_login_time(db, obj.username)
await db.refresh(user)
access_token = await create_access_token(
access_token_data = await create_access_token(
user.id,
multi_login=user.is_multi_login,
# extra info
@@ -115,16 +126,16 @@ class AuthService:
browser=ctx.browser,
device=ctx.device,
)
refresh_token = await create_refresh_token(
access_token.session_uuid,
refresh_token_data = await create_refresh_token(
access_token_data.session_uuid,
user.id,
multi_login=user.is_multi_login,
)
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=refresh_token.refresh_token,
value=refresh_token_data.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.to_utc(refresh_token.refresh_token_expire_time),
expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
httponly=True,
)
except errors.NotFoundError as e:
@@ -157,9 +168,10 @@ class AuthService:
msg=t('success.login.success'),
)
data = GetLoginToken(
access_token=access_token.access_token,
access_token_expire_time=access_token.access_token_expire_time,
session_uuid=access_token.session_uuid,
access_token=access_token_data.access_token,
access_token_expire_time=access_token_data.access_token_expire_time,
session_uuid=access_token_data.session_uuid,
password_expire_days_remaining=days_remaining,
user=user, # type: ignore
)
return data
@@ -0,0 +1,118 @@
import math
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.common.exception import errors
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.timezone import timezone
class UserPasswordHistoryService:
"""用户密码历史服务类"""
@staticmethod
async def check_status(user_id: int, user_status: int) -> None:
"""
检查用户状态
:param user_id: 用户 ID
:param user_status: 用户状态
:return:
"""
if not user_status:
raise errors.AuthorizationError(msg='用户已被锁定, 请联系统管理员')
locked_until_str = await redis_client.get(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
if locked_until_str:
locked_until = timezone.from_str(locked_until_str)
now = timezone.now()
if locked_until > now:
remaining_minutes = math.ceil((locked_until - now).total_seconds() / 60)
raise errors.AuthorizationError(msg=f'账号已被锁定,请在 {remaining_minutes} 分钟后重试')
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
@staticmethod
async def handle_login_failure(db: AsyncSession, user_id: int) -> None:
"""
处理登录失败
:param db: 数据库会话
:param user_id: 用户 ID
:return:
"""
await load_user_security_config(db)
if settings.USER_LOCK_THRESHOLD == 0:
return
failure_count = await redis_client.get(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
failure_count = int(failure_count) if failure_count else 0
failure_count += 1
await redis_client.set(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}', str(failure_count))
if failure_count >= settings.USER_LOCK_THRESHOLD:
locked_until = timezone.now() + timedelta(seconds=settings.USER_LOCK_SECONDS)
await redis_client.set(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}', timezone.to_str(locked_until))
raise errors.AuthorizationError(msg='登录失败次数过多,账号已被锁定')
@staticmethod
async def check_password_expiry_status(db: AsyncSession, password_changed_time: datetime) -> int | None:
"""
检查密码过期状态
:param db: 数据库会话
:param password_changed_time: 密码修改时间
:return:
"""
await load_user_security_config(db)
if settings.USER_PASSWORD_EXPIRY_DAYS == 0:
return None
if not password_changed_time:
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
expiry_time = password_changed_time + timedelta(days=settings.USER_PASSWORD_EXPIRY_DAYS)
days_remaining = (expiry_time - timezone.now()).days
if days_remaining < 0:
raise errors.AuthorizationError(msg='密码已过期,请修改密码后重新登录')
if days_remaining <= settings.USER_PASSWORD_REMINDER_DAYS:
return days_remaining
return None
@staticmethod
async def handle_login_success(user_id: int) -> None:
"""
处理登录成功
:param user_id: 用户 ID
:return:
"""
await redis_client.delete(f'{settings.USER_LOCK_REDIS_PREFIX}:{user_id}')
await redis_client.delete(f'{settings.LOGIN_FAILURE_PREFIX}:{user_id}')
@staticmethod
async def save_password_history(db: AsyncSession, obj: CreateUserPasswordHistoryParam) -> None:
"""
保存密码历史记录
:param db: 数据库会话
:param obj: 创建密码历史记录参数
:return:
"""
await user_password_history_dao.create(db, obj)
password_security_service: UserPasswordHistoryService = UserPasswordHistoryService()
+25 -6
View File
@@ -15,12 +15,15 @@ from backend.app.admin.schema.user import (
ResetPasswordParam,
UpdateUserParam,
)
from backend.app.admin.schema.user_password_history import CreateUserPasswordHistoryParam
from backend.app.admin.service.user_password_history_service import password_security_service
from backend.app.admin.utils.password_security import password_verify, validate_new_password
from backend.common.context import ctx
from backend.common.enums import UserPermissionType
from backend.common.exception import errors
from backend.common.pagination import paging_data
from backend.common.response.response_code import CustomErrorCode
from backend.common.security.jwt import get_token, jwt_decode, password_verify
from backend.common.security.jwt import get_token, jwt_decode
from backend.core.conf import settings
from backend.database.redis import redis_client
from backend.utils.serializers import select_join_serialize
@@ -119,7 +122,7 @@ class UserService:
for role_id in obj.roles:
if not await role_dao.get(db, role_id):
raise errors.NotFoundError(msg='角色不存在')
count = await user_dao.update(db, user, obj)
count = await user_dao.update(db, user.id, obj)
await redis_client.delete(f'{settings.JWT_USER_REDIS_PREFIX}:{user.id}')
return count
@@ -197,7 +200,14 @@ class UserService:
user = await user_dao.get(db, pk)
if not user:
raise errors.NotFoundError(msg='用户不存在')
await validate_new_password(db, user.id, password)
count = await user_dao.reset_password(db, user.id, password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user.id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user.id}',
@@ -257,21 +267,30 @@ class UserService:
return count
@staticmethod
async def update_password(*, db: AsyncSession, user_id: int, hash_password: str, obj: ResetPasswordParam) -> int:
async def update_password(*, db: AsyncSession, user_id: int, obj: ResetPasswordParam) -> int:
"""
更新当前用户密码
:param db: 数据库会话
:param user_id: 用户 ID
:param hash_password: 哈希密码
:param obj: 密码重置参数
:return:
"""
if not password_verify(obj.old_password, hash_password):
user = await user_dao.get(db, user_id)
if user.password and not password_verify(obj.old_password, user.password):
raise errors.RequestError(msg='原密码错误')
if obj.new_password != obj.confirm_password:
raise errors.RequestError(msg='密码输入不一致')
raise errors.RequestError(msg='两次密码输入不一致')
await validate_new_password(db, user_id, obj.new_password)
count = await user_dao.reset_password(db, user_id, obj.new_password)
history_obj = CreateUserPasswordHistoryParam(user_id=user.id, password=user.password)
await password_security_service.save_password_history(db, history_obj)
await user_dao.update_password_changed_time(db, user.id)
key_prefix = [
f'{settings.TOKEN_REDIS_PREFIX}:{user_id}',
f'{settings.TOKEN_REFRESH_REDIS_PREFIX}:{user_id}',
@@ -0,0 +1,68 @@
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.admin.crud.crud_user_password_history import user_password_history_dao
from backend.common.exception import errors
from backend.core.conf import settings
from backend.utils.dynamic_config import load_user_security_config
from backend.utils.re_verify import is_has_letter, is_has_number, is_has_special_char
password_hash = PasswordHash((BcryptHasher(),))
def get_hash_password(password: str, salt: bytes | None) -> str:
"""
使用哈希算法加密密码
:param password: 密码
:param salt: 盐值
:return:
"""
return password_hash.hash(password, salt=salt)
def password_verify(plain_password: str, hashed_password: str) -> bool:
"""
密码验证
:param plain_password: 待验证的密码
:param hashed_password: 哈希密码
:return:
"""
return password_hash.verify(plain_password, hashed_password)
async def validate_new_password(db: AsyncSession, user_id: int, new_password: str) -> None:
"""
验证新密码
:param db: 数据库会话
:param user_id: 用户ID
:param new_password: 新密码
:return:
"""
await load_user_security_config(db)
if len(new_password) < settings.USER_PASSWORD_MIN_LENGTH:
raise errors.RequestError(msg=f'密码长度不能少于 {settings.USER_PASSWORD_MIN_LENGTH} 个字符')
if len(new_password) > settings.USER_PASSWORD_MAX_LENGTH:
raise errors.RequestError(msg=f'密码长度不能超过 {settings.USER_PASSWORD_MAX_LENGTH} 个字符')
if not is_has_number(new_password):
raise errors.RequestError(msg='密码必须包含数字')
if not is_has_letter(new_password):
raise errors.RequestError(msg='密码必须包含字母')
if settings.USER_PASSWORD_REQUIRE_SPECIAL_CHAR and not is_has_special_char(new_password):
raise errors.RequestError(msg='密码必须包含特殊字符(如:!@#$%')
password_history = await user_password_history_dao.get_by_user_id(db, user_id)
for hist in password_history[: settings.USER_PASSWORD_HISTORY_CHECK_COUNT]:
if password_verify(new_password, hist.password):
raise errors.RequestError(
msg=f'新密码不能与最近 {settings.USER_PASSWORD_HISTORY_CHECK_COUNT} 次使用的密码相同'
)
+2 -28
View File
@@ -1,16 +1,14 @@
import json
import uuid
from datetime import timedelta
from typing import Any
from uuid import uuid4
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPBearer
from fastapi.security.http import HTTPAuthorizationCredentials
from fastapi.security.utils import get_authorization_scheme_param
from jose import ExpiredSignatureError, JWTError, jwt
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from pydantic_core import from_json
from sqlalchemy.ext.asyncio import AsyncSession
@@ -44,30 +42,6 @@ class CustomHTTPBearer(HTTPBearer):
# JWT authorizes dependency injection
DependsJwtAuth = Depends(CustomHTTPBearer())
password_hash = PasswordHash((BcryptHasher(),))
def get_hash_password(password: str, salt: bytes | None) -> str:
"""
使用哈希算法加密密码
:param password: 密码
:param salt: 盐值
:return:
"""
return password_hash.hash(password, salt=salt)
def password_verify(plain_password: str, hashed_password: str) -> bool:
"""
密码验证
:param plain_password: 待验证的密码
:param hashed_password: 哈希密码
:return:
"""
return password_hash.verify(plain_password, hashed_password)
def jwt_encode(payload: dict[str, Any]) -> str:
"""
@@ -119,7 +93,7 @@ async def create_access_token(user_id: int, *, multi_login: bool, **kwargs) -> A
:return:
"""
expire = timezone.now() + timedelta(seconds=settings.TOKEN_EXPIRE_SECONDS)
session_uuid = str(uuid4())
session_uuid = str(uuid.uuid4())
access_token = jwt_encode({
'session_uuid': session_uuid,
'exp': timezone.to_utc(expire).timestamp(),
+17 -4
View File
@@ -70,6 +70,23 @@ class Settings(BaseSettings):
rf'^{FASTAPI_API_V1_PATH}/monitors/(redis|server)$',
]
# 用户安全
USER_LOCK_REDIS_PREFIX: str = 'fba:user:lock'
USER_LOCK_THRESHOLD: int = 5 # 用户密码错误锁定阈值,0 表示禁用锁定
USER_LOCK_SECONDS: int = 60 * 5 # 5 分钟
USER_PASSWORD_EXPIRY_DAYS: int = 365 # 用户密码有效期,0 表示永不过期
USER_PASSWORD_REMINDER_DAYS: int = 7 # 用户密码到期提醒,0 表示不提醒
USER_PASSWORD_HISTORY_CHECK_COUNT: int = 3
USER_PASSWORD_MIN_LENGTH: int = 6
USER_PASSWORD_MAX_LENGTH: int = 32
USER_PASSWORD_REQUIRE_SPECIAL_CHAR: bool = False
# 登录
LOGIN_CAPTCHA_ENABLED: bool = True
LOGIN_CAPTCHA_REDIS_PREFIX: str = 'fba:login:captcha'
LOGIN_CAPTCHA_EXPIRE_SECONDS: int = 60 * 5 # 5 分钟
LOGIN_FAILURE_PREFIX: str = 'fba:login:failure'
# JWT
JWT_USER_REDIS_PREFIX: str = 'fba:user'
@@ -84,10 +101,6 @@ class Settings(BaseSettings):
COOKIE_REFRESH_TOKEN_KEY: str = 'fba_refresh_token'
COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7 # 7 天
# 验证码
CAPTCHA_LOGIN_REDIS_PREFIX: str = 'fba:login:captcha'
CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟
# 数据权限
DATA_PERMISSION_MODELS: dict[str, str] = { # 允许进行数据过滤的 SQLA 模型,它必须以模块字符串的方式定义
'Dept': 'backend.app.admin.model.Dept',
+1
View File
@@ -2,6 +2,7 @@
"error": {
"captcha": {
"error": "Captcha error",
"invalid": "Captcha is invalid, please try again",
"expired": "Captcha has expired, please try again"
},
"language_not_found": "Current language pack is not initialized or does not exist"
+1
View File
@@ -1,6 +1,7 @@
error:
captcha:
error: 验证码错误
invalid: 验证码无效,请重新获取
expired: 验证码已过期,请重新获取
language_not_found: 当前语言包未初始化或不存在
pydantic:
+9
View File
@@ -0,0 +1,9 @@
from backend.common.enums import StrEnum
class ConfigType(StrEnum):
"""配置类型"""
email = 'EMAIL'
user_security = 'USER_SECURITY'
login = 'LOGIN'
+12 -1
View File
@@ -5,4 +5,15 @@ values
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', 'true', false, null, now(), null),
(7, '状态', 'USER_SECURITY', 'USER_SECURITY_CONFIG_STATUS', '1', false, null, now(), null),
(8, '密码错误锁定阈值', 'USER_SECURITY', 'USER_LOCK_THRESHOLD', '5', false, '0 表示禁用锁定', now(), null),
(9, '密码错误锁定时长(秒)', 'USER_SECURITY', 'USER_LOCK_SECONDS', '300', false, null, now(), null),
(10, '密码有效期(天)', 'USER_SECURITY', 'USER_PASSWORD_EXPIRY_DAYS', '365', false, '0 表示永不过期', now(), null),
(11, '密码到期提醒(天)', 'USER_SECURITY', 'USER_PASSWORD_REMINDER_DAYS', '7', false, '0 表示不提醒', now(), null),
(12, '密码历史检查次数', 'USER_SECURITY', 'USER_PASSWORD_HISTORY_CHECK_COUNT', '3', false, null, now(), null),
(13, '密码最小长度', 'USER_SECURITY', 'USER_PASSWORD_MIN_LENGTH', '6', false, null, now(), null),
(14, '密码最大长度', 'USER_SECURITY', 'USER_PASSWORD_MAX_LENGTH', '32', false, null, now(), null),
(15, '密码必须包含特殊字符', 'USER_SECURITY', 'USER_PASSWORD_REQUIRE_SPECIAL_CHAR', 'false', false, null, now(), null),
(16, '状态', 'LOGIN', 'LOGIN_CONFIG_STATUS', '1', false, null, now(), null),
(17, '验证码开关', 'LOGIN', 'LOGIN_CAPTCHA_ENABLED', 'true', false, null, now(), null);
@@ -5,4 +5,15 @@ values
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', 'true', false, null, now(), null),
(2069061886627938310, '状态', 'USER_SECURITY', 'USER_SECURITY_CONFIG_STATUS', '1', false, null, now(), null),
(2069061886627938311, '密码错误锁定阈值', 'USER_SECURITY', 'USER_LOCK_THRESHOLD', '5', false, '0 表示禁用锁定', now(), null),
(2069061886627938312, '密码错误锁定时长(秒)', 'USER_SECURITY', 'USER_LOCK_SECONDS', '300', false, null, now(), null),
(2069061886627938313, '密码有效期(天)', 'USER_SECURITY', 'USER_PASSWORD_EXPIRY_DAYS', '365', false, '0 表示永不过期', now(), null),
(2069061886627938314, '密码到期提醒(天)', 'USER_SECURITY', 'USER_PASSWORD_REMINDER_DAYS', '7', false, '0 表示不提醒', now(), null),
(2069061886627938315, '密码历史检查次数', 'USER_SECURITY', 'USER_PASSWORD_HISTORY_CHECK_COUNT', '3', false, null, now(), null),
(2069061886627938316, '密码最小长度', 'USER_SECURITY', 'USER_PASSWORD_MIN_LENGTH', '6', false, null, now(), null),
(2069061886627938317, '密码最大长度', 'USER_SECURITY', 'USER_PASSWORD_MAX_LENGTH', '32', false, null, now(), null),
(2069061886627938318, '密码必须包含特殊字符', 'USER_SECURITY', 'USER_PASSWORD_REQUIRE_SPECIAL_CHAR', 'false', false, null, now(), null),
(2069061886627938319, '状态', 'LOGIN', 'LOGIN_CONFIG_STATUS', '1', false, null, now(), null),
(2069061886627938320, '验证码开关', 'LOGIN', 'LOGIN_CAPTCHA_ENABLED', 'true', false, null, now(), null);
+12 -1
View File
@@ -5,6 +5,17 @@ values
(3, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(4, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(5, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
(6, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', 'true', false, null, now(), null),
(7, '状态', 'USER_SECURITY', 'USER_SECURITY_CONFIG_STATUS', '1', false, null, now(), null),
(8, '密码错误锁定阈值', 'USER_SECURITY', 'USER_LOCK_THRESHOLD', '5', false, '0 表示禁用锁定', now(), null),
(9, '密码错误锁定时长(秒)', 'USER_SECURITY', 'USER_LOCK_SECONDS', '300', false, null, now(), null),
(10, '密码有效期(天)', 'USER_SECURITY', 'USER_PASSWORD_EXPIRY_DAYS', '365', false, '0 表示永不过期', now(), null),
(11, '密码到期提醒(天)', 'USER_SECURITY', 'USER_PASSWORD_REMINDER_DAYS', '7', false, '0 表示不提醒', now(), null),
(12, '密码历史检查次数', 'USER_SECURITY', 'USER_PASSWORD_HISTORY_CHECK_COUNT', '3', false, null, now(), null),
(13, '密码最小长度', 'USER_SECURITY', 'USER_PASSWORD_MIN_LENGTH', '6', false, null, now(), null),
(14, '密码最大长度', 'USER_SECURITY', 'USER_PASSWORD_MAX_LENGTH', '32', false, null, now(), null),
(15, '密码必须包含特殊字符', 'USER_SECURITY', 'USER_PASSWORD_REQUIRE_SPECIAL_CHAR', 'false', false, null, now(), null),
(16, '状态', 'LOGIN', 'LOGIN_CONFIG_STATUS', '1', false, null, now(), null),
(17, '验证码开关', 'LOGIN', 'LOGIN_CAPTCHA_ENABLED', 'true', false, null, now(), null);
select setval(pg_get_serial_sequence('sys_config', 'id'),coalesce(max(id), 0) + 1, true) from sys_config;
@@ -5,4 +5,15 @@ values
(2069061886627938306, '服务器端口', 'EMAIL', 'EMAIL_PORT', '465', false, null, now(), null),
(2069061886627938307, '邮箱账号', 'EMAIL', 'EMAIL_USERNAME', 'fba@qq.com', false, null, now(), null),
(2069061886627938308, '邮箱密码', 'EMAIL', 'EMAIL_PASSWORD', '', false, null, now(), null),
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', '1', false, null, now(), null);
(2069061886627938309, 'SSL 加密', 'EMAIL', 'EMAIL_SSL', 'true', false, null, now(), null),
(2069061886627938310, '状态', 'USER_SECURITY', 'USER_SECURITY_CONFIG_STATUS', '1', false, null, now(), null),
(2069061886627938311, '密码错误锁定阈值', 'USER_SECURITY', 'USER_LOCK_THRESHOLD', '5', false, '0 表示禁用锁定', now(), null),
(2069061886627938312, '密码错误锁定时长(秒)', 'USER_SECURITY', 'USER_LOCK_SECONDS', '300', false, null, now(), null),
(2069061886627938313, '密码有效期(天)', 'USER_SECURITY', 'USER_PASSWORD_EXPIRY_DAYS', '365', false, '0 表示永不过期', now(), null),
(2069061886627938314, '密码到期提醒(天)', 'USER_SECURITY', 'USER_PASSWORD_REMINDER_DAYS', '7', false, '0 表示不提醒', now(), null),
(2069061886627938315, '密码历史检查次数', 'USER_SECURITY', 'USER_PASSWORD_HISTORY_CHECK_COUNT', '3', false, null, now(), null),
(2069061886627938316, '密码最小长度', 'USER_SECURITY', 'USER_PASSWORD_MIN_LENGTH', '6', false, null, now(), null),
(2069061886627938317, '密码最大长度', 'USER_SECURITY', 'USER_PASSWORD_MAX_LENGTH', '32', false, null, now(), null),
(2069061886627938318, '密码必须包含特殊字符', 'USER_SECURITY', 'USER_PASSWORD_REQUIRE_SPECIAL_CHAR', 'false', false, null, now(), null),
(2069061886627938319, '状态', 'LOGIN', 'LOGIN_CONFIG_STATUS', '1', false, null, now(), null),
(2069061886627938320, '验证码开关', 'LOGIN', 'LOGIN_CAPTCHA_ENABLED', 'true', false, null, now(), null);
+9 -49
View File
@@ -4,17 +4,12 @@ from email.mime.text import MIMEText
from aiosmtplib import SMTP
from anyio import open_file
from jinja2 import Template
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from sqlalchemy.ext.asyncio import AsyncSession
from backend.common.enums import StatusType
from backend.common.exception import errors
from backend.common.log import log
from backend.core.conf import settings
from backend.core.path_conf import PLUGIN_DIR
from backend.database.db import async_engine
from backend.plugin.config.crud.crud_config import config_dao
from backend.utils.serializers import select_list_serialize
from backend.utils.dynamic_config import load_email_config
from backend.utils.timezone import timezone
@@ -62,52 +57,17 @@ async def send_email(
:param template: 邮件内容模板
:return:
"""
# 本地配置
email_host = settings.EMAIL_HOST
email_port = settings.EMAIL_PORT
email_ssl = settings.EMAIL_SSL
email_username = settings.EMAIL_USERNAME
email_password = settings.EMAIL_PASSWORD
# 动态配置
dynamic_config = None
def get_config_table(conn: AsyncConnection) -> bool:
inspector = inspect(conn)
return inspector.has_table('sys_config', schema=None)
async with async_engine.begin() as coon:
exists = await coon.run_sync(get_config_table)
if exists:
dynamic_config = await config_dao.get_all(db, 'EMAIL')
if dynamic_config:
status_key = 'EMAIL_STATUS'
host_key = 'EMAIL_HOST'
port_key = 'EMAIL_PORT'
ssl_key = 'EMAIL_SSL'
username_key = 'EMAIL_USERNAME'
password_key = 'EMAIL_PASSWORD'
configs = {d['key']: d['value'] for d in select_list_serialize(dynamic_config)}
if configs.get(status_key):
if len(dynamic_config) < 6:
raise errors.NotFoundError(msg='缺少邮件动态配置,请检查系统参数配置-邮件配置')
email_host = configs.get(host_key)
email_port = int(configs.get(port_key, 0))
email_ssl = configs.get(ssl_key, '') == str(StatusType.enable.value)
email_username = configs.get(username_key)
email_password = configs.get(password_key)
await load_email_config(db)
try:
message = await render_message(subject, email_username, content, template)
message = await render_message(subject, settings.EMAIL_USERNAME, content, template)
smtp_client = SMTP(
hostname=email_host,
port=email_port,
use_tls=email_ssl,
hostname=settings.EMAIL_HOST,
port=settings.EMAIL_PORT,
use_tls=settings.EMAIL_SSL,
)
async with smtp_client:
await smtp_client.login(email_username, email_password)
await smtp_client.sendmail(email_username, recipients, message)
await smtp_client.login(settings.EMAIL_USERNAME, settings.EMAIL_PASSWORD)
await smtp_client.sendmail(settings.EMAIL_USERNAME, recipients, message)
except Exception as e:
log.error(f'电子邮件发送失败:{e}')
+10 -10
View File
@@ -86,20 +86,20 @@ class OAuth2Service:
await user_social_dao.create(db, new_user_social)
# 创建 token
access_token = await jwt.create_access_token(
access_token_data = await jwt.create_access_token(
sys_user.id,
multi_login=sys_user.is_multi_login,
# extra info
username=sys_user.username,
nickname=sys_user.nickname or f'#{text_captcha(5)}',
nickname=sys_user.nickname,
last_login_time=timezone.to_str(timezone.now()),
ip=ctx.ip,
os=ctx.os,
browser=ctx.browser,
device=ctx.device,
)
refresh_token = await jwt.create_refresh_token(
access_token.session_uuid,
refresh_token_data = await jwt.create_refresh_token(
access_token_data.session_uuid,
sys_user.id,
multi_login=sys_user.is_multi_login,
)
@@ -114,18 +114,18 @@ class OAuth2Service:
status=LoginLogStatusType.success.value,
msg=t('success.login.oauth2_success'),
)
await redis_client.delete(f'{settings.CAPTCHA_LOGIN_REDIS_PREFIX}:{ctx.ip}')
await redis_client.delete(f'{settings.LOGIN_CAPTCHA_REDIS_PREFIX}:{ctx.ip}')
response.set_cookie(
key=settings.COOKIE_REFRESH_TOKEN_KEY,
value=refresh_token.refresh_token,
value=refresh_token_data.refresh_token,
max_age=settings.COOKIE_REFRESH_TOKEN_EXPIRE_SECONDS,
expires=timezone.to_utc(refresh_token.refresh_token_expire_time),
expires=timezone.to_utc(refresh_token_data.refresh_token_expire_time),
httponly=True,
)
data = GetLoginToken(
access_token=access_token.access_token,
access_token_expire_time=access_token.access_token_expire_time,
session_uuid=access_token.session_uuid,
access_token=access_token_data.access_token,
access_token_expire_time=access_token_data.access_token_expire_time,
session_uuid=access_token_data.session_uuid,
user=sys_user, # type: ignore
)
return data
@@ -91,10 +91,10 @@ values
(2048601263708438528, 2048601263515500544, 2049629108245233666),
(2048601263775547392, 2048601263515500544, 2049629108253622282);
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, last_password_changed_time, dept_id, created_time, updated_time)
values
(2048601263834267648, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 2048601258595581952, now(), null),
(2049946297615646720, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 2048601258595581952, now(), null);
(2048601263834267648, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), now(), 2048601258595581952, now(), null),
(2049946297615646720, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 1, false, false, false, null, null, now(), now(), now(), 2048601258595581952, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
+3 -3
View File
@@ -91,10 +91,10 @@ values
(3, 1, 3),
(4, 1, 53);
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, last_password_changed_time, dept_id, created_time, updated_time)
values
(1, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 1, now(), null),
(2, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 1, now(), null);
(1, uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', unhex('24326224313224387932654E7563583139566A6D5A33745968424C634F'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), now(), 1, now(), null),
(2, uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', unhex('24326224313224424D6958734E514167547837614E63376B56676E7765'), 'test@example.com', 1, false, false, false, null, null, now(), now(), now(), 1, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
@@ -91,10 +91,10 @@ values
(2048601269546909696, 2048601269345583104, 2049629108245233666),
(2048601269609824256, 2048601269345583104, 2049629108253622282);
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, last_password_changed_time, dept_id, created_time, updated_time)
values
(2048601269672738816, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 2048601264366944256, now(), null),
(2049946297615646720, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 2048601264366944256, now(), null);
(2048601269672738816, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), now(), 2048601264366944256, now(), null),
(2049946297615646720, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 1, false, false, false, null, null, now(), now(), now(), 2048601264366944256, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
+3 -3
View File
@@ -91,10 +91,10 @@ values
(3, 1, 3),
(4, 1, 53);
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, dept_id, created_time, updated_time)
insert into sys_user (id, uuid, username, nickname, password, salt, email, status, is_superuser, is_staff, is_multi_login, avatar, phone, join_time, last_login_time, last_password_changed_time, dept_id, created_time, updated_time)
values
(1, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), 1, now(), null),
(2, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 1, false, false, false, null, null, now(), now(), 1, now(), null);
(1, gen_random_uuid(), 'admin', '用户88888', '$2b$12$8y2eNucX19VjmZ3tYhBLcOsBwy9w1IjBQE4SSqwMDL5bGQVp2wqS.', decode('24326224313224387932654E7563583139566A6D5A33745968424C634F', 'hex'), 'admin@example.com', 1, true, true, true, null, null, now(), now(), now(), 1, now(), null),
(2, gen_random_uuid(), 'test', '用户66666', '$2b$12$BMiXsNQAgTx7aNc7kVgnwedXGyUxPEHRnJMFbiikbqHgVoT3y14Za', decode('24326224313224424D6958734E514167547837614E63376B56676E7765', 'hex'), 'test@example.com', 1, false, false, false, null, null, now(), now(), now(), 1, now(), null);
insert into sys_user_role (id, user_id, role_id)
values
+132
View File
@@ -0,0 +1,132 @@
from functools import lru_cache
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
from backend.core.conf import settings
from backend.database.db import async_engine
from backend.utils.serializers import select_list_serialize
@lru_cache
def get_sys_config_table(conn: AsyncConnection) -> bool:
"""
获取参数配置表
:param conn: 数据库连接
:return:
"""
inspector = inspect(conn)
return inspector.has_table('sys_config', schema=None)
async def load_user_security_config(db: AsyncSession) -> None: # noqa: C901
"""
获取用户安全配置
:param db: 数据库会话
:return:
"""
dynamic_config = None
async with async_engine.begin() as conn:
exists = await conn.run_sync(get_sys_config_table)
if exists:
from backend.plugin.config.crud.crud_config import config_dao
from backend.plugin.config.enums import ConfigType
dynamic_config = await config_dao.get_all(db, ConfigType.user_security)
if dynamic_config:
security_config_status_key = 'USER_SECURITY_CONFIG_STATUS'
lock_threshold_key = 'USER_LOCK_THRESHOLD'
lock_seconds_key = 'USER_LOCK_SECONDS'
password_expiry_days_key = 'USER_PASSWORD_EXPIRY_DAYS'
password_reminder_days_key = 'USER_PASSWORD_REMINDER_DAYS'
password_history_check_count_key = 'USER_PASSWORD_HISTORY_CHECK_COUNT'
password_min_length_key = 'USER_PASSWORD_MIN_LENGTH'
password_max_length_key = 'USER_PASSWORD_MAX_LENGTH'
password_require_special_char_key = 'USER_PASSWORD_REQUIRE_SPECIAL_CHAR'
configs = {dc['key']: dc['value'] for dc in select_list_serialize(dynamic_config)}
if int(configs.get(security_config_status_key)):
if lock_threshold_key in configs:
settings.USER_LOCK_THRESHOLD = int(configs[lock_threshold_key])
if lock_seconds_key in configs:
settings.USER_LOCK_SECONDS = int(configs[lock_seconds_key])
if password_expiry_days_key in configs:
settings.USER_PASSWORD_EXPIRY_DAYS = int(configs[password_expiry_days_key])
if password_reminder_days_key in configs:
settings.USER_PASSWORD_REMINDER_DAYS = int(configs[password_reminder_days_key])
if password_history_check_count_key in configs:
settings.USER_PASSWORD_HISTORY_CHECK_COUNT = int(configs[password_history_check_count_key])
if password_min_length_key in configs:
settings.USER_PASSWORD_MIN_LENGTH = int(configs[password_min_length_key])
if password_max_length_key in configs:
settings.USER_PASSWORD_MAX_LENGTH = int(configs[password_max_length_key])
if password_require_special_char_key in configs:
settings.USER_PASSWORD_REQUIRE_SPECIAL_CHAR = configs[password_require_special_char_key] == 'true'
async def load_login_config(db: AsyncSession) -> None:
"""
获取登录配置
:param db: 数据库会话
:return:
"""
dynamic_config = None
async with async_engine.begin() as conn:
exists = await conn.run_sync(get_sys_config_table)
if exists:
from backend.plugin.config.crud.crud_config import config_dao
from backend.plugin.config.enums import ConfigType
dynamic_config = await config_dao.get_all(db, ConfigType.login)
if dynamic_config:
login_config_status_key = 'LOGIN_CONFIG_STATUS'
login_captcha_enabled_key = 'LOGIN_CAPTCHA_ENABLED'
configs = {dc['key']: dc['value'] for dc in select_list_serialize(dynamic_config)}
if int(configs.get(login_config_status_key)) and login_captcha_enabled_key in configs:
settings.LOGIN_CAPTCHA_ENABLED = configs[login_captcha_enabled_key] == 'true'
async def load_email_config(db: AsyncSession) -> None:
"""
获取邮箱配置
:param db: 数据库会话
:return:
"""
dynamic_config = None
async with async_engine.begin() as conn:
exists = await conn.run_sync(get_sys_config_table)
if exists:
from backend.plugin.config.crud.crud_config import config_dao
from backend.plugin.config.enums import ConfigType
dynamic_config = await config_dao.get_all(db, ConfigType.email)
if dynamic_config:
email_config_status_key = 'EMAIL_CONFIG_STATUS'
host_key = 'EMAIL_HOST'
port_key = 'EMAIL_PORT'
ssl_key = 'EMAIL_SSL'
username_key = 'EMAIL_USERNAME'
password_key = 'EMAIL_PASSWORD'
configs = {dc['key']: dc['value'] for dc in select_list_serialize(dynamic_config)}
if int(configs.get(email_config_status_key)):
settings.EMAIL_HOST = str(configs[host_key])
if configs.get(port_key):
settings.EMAIL_PORT = int(configs[port_key])
if configs.get(ssl_key):
settings.EMAIL_SSL = configs[ssl_key] == 'true'
if configs.get(username_key):
settings.EMAIL_USERNAME = str(configs[username_key])
if configs.get(password_key):
settings.EMAIL_PASSWORD = str(configs[password_key])
+39 -20
View File
@@ -1,7 +1,7 @@
import re
def search_string(pattern: str, text: str) -> re.Match[str] | None:
def search_string(pattern: str, text: str) -> re.Match[str]:
"""
全字段正则匹配
@@ -9,14 +9,10 @@ def search_string(pattern: str, text: str) -> re.Match[str] | None:
:param text: 待匹配的文本
:return:
"""
if not pattern or not text:
return None
result = re.search(pattern, text)
return result
return re.search(pattern, text)
def match_string(pattern: str, text: str) -> re.Match[str] | None:
def match_string(pattern: str, text: str) -> re.Match[str]:
"""
从字段开头正则匹配
@@ -24,36 +20,59 @@ def match_string(pattern: str, text: str) -> re.Match[str] | None:
:param text: 待匹配的文本
:return:
"""
if not pattern or not text:
return None
result = re.match(pattern, text)
return result
return re.match(pattern, text)
def is_phone(number: str) -> re.Match[str] | None:
def is_phone(number: str) -> re.Match[str]:
"""
检查手机号码格式
:param number: 待检查的手机号码
:return:
"""
if not number:
return None
phone_pattern = r'^1[3-9]\d{9}$'
return match_string(phone_pattern, number)
def is_git_url(url: str) -> re.Match[str] | None:
def is_git_url(url: str) -> re.Match[str]:
"""
检查 git URL 格式
:param url: 待检查的 URL
:return:
"""
if not url:
return None
git_pattern = r'^(?!(git\+ssh|ssh)://|git@)(?P<scheme>git|https?|file)://(?P<host>[^/]*)(?P<path>(?:/[^/]*)*/)(?P<repo>[^/]+?)(?:\.git)?$'
return match_string(git_pattern, url)
def is_has_number(value: str) -> re.Match[str]:
"""
检查数字
:param value: 待检查的值
:return:
"""
number_pattern = r'\d'
return search_string(number_pattern, value)
def is_has_letter(value: str) -> re.Match[str]:
"""
检查字母
:param value: 待检查的值
:return:
"""
letter_pattern = r'[a-zA-Z]'
return search_string(letter_pattern, value)
def is_has_special_char(value: str) -> re.Match[str]:
"""
检查特殊字符
:param value: 待检查的值
:return:
"""
special_char_pattern = r'[!@#$%^&*()_+\-=\[\]{};:\'",.<>?/\\|`~]'
return search_string(special_char_pattern, value)