mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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='创建时间',
|
||||
)
|
||||
@@ -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='图片内容')
|
||||
|
||||
@@ -30,6 +30,7 @@ class GetNewToken(AccessTokenBase):
|
||||
class GetLoginToken(AccessTokenBase):
|
||||
"""获取登录令牌"""
|
||||
|
||||
password_expire_days_remaining: int | None = Field(None, description='密码过期剩余天数')
|
||||
user: GetUserInfoDetail = Field(description='用户信息')
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""创建用户历史密码记录"""
|
||||
@@ -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()
|
||||
@@ -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} 次使用的密码相同'
|
||||
)
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
error:
|
||||
captcha:
|
||||
error: 验证码错误
|
||||
invalid: 验证码无效,请重新获取
|
||||
expired: 验证码已过期,请重新获取
|
||||
language_not_found: 当前语言包未初始化或不存在
|
||||
pydantic:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from backend.common.enums import StrEnum
|
||||
|
||||
|
||||
class ConfigType(StrEnum):
|
||||
"""配置类型"""
|
||||
|
||||
email = 'EMAIL'
|
||||
user_security = 'USER_SECURITY'
|
||||
login = 'LOGIN'
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user