From 8d40004c671d1084c7cfb2237c4e5a2812161dc0 Mon Sep 17 00:00:00 2001 From: wxg0103 <727495428@qq.com> Date: Sat, 7 Jun 2025 19:03:18 +0800 Subject: [PATCH] refactor: add logout --- apps/users/api/user.py | 30 +++- apps/users/serializers/user.py | 177 ++++++++++++++++++- apps/users/urls.py | 5 + apps/users/views/login.py | 21 +++ apps/users/views/user.py | 74 +++++++- ui/src/api/system-api-key.ts | 58 ++++++ ui/src/api/user/user-manage.ts | 16 +- ui/src/layout/layout-header/avatar/index.vue | 7 +- ui/src/views/login/ResetPassword.vue | 2 +- 9 files changed, 380 insertions(+), 10 deletions(-) create mode 100644 ui/src/api/system-api-key.ts diff --git a/apps/users/api/user.py b/apps/users/api/user.py index 2c22cf270..9e217b837 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -10,9 +10,9 @@ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter from common.mixins.api_mixin import APIMixin -from common.result import ResultSerializer +from common.result import ResultSerializer, DefaultResultSerializer from users.serializers.user import UserProfileResponse, CreateUserSerializer, UserManageSerializer, \ - UserInstanceSerializer + UserInstanceSerializer, RePasswordSerializer, CheckCodeSerializer, SendEmailSerializer from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -192,3 +192,29 @@ class TestWorkspacePermissionUserApi(APIMixin): # 指定必须给 required=True, )] + + +class ResetPasswordAPI(APIMixin): + @staticmethod + def get_request(): + return RePasswordSerializer + + +class CheckCodeAPI(APIMixin): + @staticmethod + def get_request(): + return CheckCodeSerializer + + @staticmethod + def get_response(): + return DefaultResultSerializer + + +class SendEmailAPI(APIMixin): + @staticmethod + def get_request(): + return SendEmailSerializer + + @staticmethod + def get_response(): + return DefaultResultSerializer diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index 461dd7d4b..bdb8a3a1c 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -6,10 +6,13 @@ @date:2025/4/14 19:18 @desc: """ +import datetime +import os +import random import re from collections import defaultdict from itertools import product - +from django.core.mail.backends.smtp import EmailBackend from django.db import transaction from django.db.models import Q, QuerySet from rest_framework import serializers @@ -20,9 +23,13 @@ from common.database_model_manage.database_model_manage import DatabaseModelMana from common.db.search import page_search from common.exception.app_exception import AppApiException from common.utils.common import valid_license, password_encrypt +from maxkb.conf import PROJECT_DIR +from system_manage.models import SystemSetting, SettingType from users.models import User -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _, to_locale from django.core import validators +from django.core.mail import send_mail +from django.utils.translation import get_language PASSWORD_REGEX = re.compile( r"^(?![a-zA-Z]+$)(?![A-Z0-9]+$)(?![A-Z_!@#$%^&*`~.()-+=]+$)(?![a-z0-9]+$)(?![a-z_!@#$%^&*`~()-+=]+$)" @@ -441,3 +448,169 @@ def update_user_role(instance, user): workspace_id=workspace_id, user_id=user.id ) + + +class RePasswordSerializer(serializers.Serializer): + email = serializers.EmailField( + required=True, + label=_("Email"), + validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message, + code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)]) + + code = serializers.CharField(required=True, label=_("Verification code")) + + password = serializers.CharField(required=True, label=_("Password"), + validators=[validators.RegexValidator(regex=re.compile( + "^(?![a-zA-Z]+$)(?![A-Z0-9]+$)(?![A-Z_!@#$%^&*`~.()-+=]+$)(?![a-z0-9]+$)(?![a-z_!@#$%^&*`~()-+=]+$)" + "(?![0-9_!@#$%^&*`~()-+=]+$)[a-zA-Z0-9_!@#$%^&*`~.()-+=]{6,20}$") + , message=_( + "The confirmation password must be 6-20 characters long and must be a combination of letters, numbers, and special characters."))]) + + re_password = serializers.CharField(required=True, label=_("Confirm Password"), + validators=[validators.RegexValidator(regex=re.compile( + "^(?![a-zA-Z]+$)(?![A-Z0-9]+$)(?![A-Z_!@#$%^&*`~.()-+=]+$)(?![a-z0-9]+$)(?![a-z_!@#$%^&*`~()-+=]+$)" + "(?![0-9_!@#$%^&*`~()-+=]+$)[a-zA-Z0-9_!@#$%^&*`~.()-+=]{6,20}$") + , message=_( + "The confirmation password must be 6-20 characters long and must be a combination of letters, numbers, and special characters."))] + ) + + class Meta: + model = User + fields = '__all__' + + def is_valid(self, *, raise_exception=False): + super().is_valid(raise_exception=True) + email = self.data.get("email") + # TODO 删除缓存 + # cache_code = user_cache.get(email + ':reset_password') + if self.data.get('password') != self.data.get('re_password'): + raise AppApiException(ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.code, + ExceptionCodeConstants.PASSWORD_NOT_EQ_RE_PASSWORD.value.message) + # if cache_code != self.data.get('code'): + # raise AppApiException(ExceptionCodeConstants.CODE_ERROR.value.code, + # ExceptionCodeConstants.CODE_ERROR.value.message) + return True + + def reset_password(self): + """ + 修改密码 + :return: 是否成功 + """ + if self.is_valid(): + email = self.data.get("email") + QuerySet(User).filter(email=email).update( + password=password_encrypt(self.data.get('password'))) + code_cache_key = email + ":reset_password" + # 删除验证码缓存 + # user_cache.delete(code_cache_key) + return True + + +class SendEmailSerializer(serializers.Serializer): + email = serializers.EmailField( + required=True + , label=_("Email"), + validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message, + code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)]) + + type = serializers.CharField(required=True, label=_("Type"), validators=[ + validators.RegexValidator(regex=re.compile("^register|reset_password$"), + message=_("The type only supports register|reset_password"), code=500) + ]) + + class Meta: + model = User + fields = '__all__' + + def is_valid(self, *, raise_exception=False): + super().is_valid(raise_exception=raise_exception) + user_exists = QuerySet(User).filter(email=self.data.get('email')).exists() + if not user_exists and self.data.get('type') == 'reset_password': + raise ExceptionCodeConstants.EMAIL_IS_NOT_EXIST.value.to_app_api_exception() + elif user_exists and self.data.get('type') == 'register': + raise ExceptionCodeConstants.EMAIL_IS_EXIST.value.to_app_api_exception() + code_cache_key = self.data.get('email') + ":" + self.data.get("type") + code_cache_key_lock = code_cache_key + "_lock" + ttl = None # user_cache.ttl(code_cache_key_lock) + if ttl is not None: + raise AppApiException(500, _("Do not send emails again within {seconds} seconds").format( + seconds=int(ttl.total_seconds()))) + return True + + def send(self): + """ + 发送邮件 + :return: 是否发送成功 + :exception 发送失败异常 + """ + email = self.data.get("email") + state = self.data.get("type") + # 生成随机验证码 + code = "".join(list(map(lambda i: random.choice(['1', '2', '3', '4', '5', '6', '7', '8', '9', '0' + ]), range(6)))) + # 获取邮件模板 + language = get_language() + file = open( + os.path.join(PROJECT_DIR, "apps", "common", 'template', f'email_template_{to_locale(language)}.html'), "r", + encoding='utf-8') + content = file.read() + file.close() + code_cache_key = email + ":" + state + code_cache_key_lock = code_cache_key + "_lock" + # 设置缓存 + # user_cache.set(code_cache_key_lock, code, timeout=datetime.timedelta(minutes=1)) + system_setting = QuerySet(SystemSetting).filter(type=SettingType.EMAIL.value).first() + if system_setting is None: + # user_cache.delete(code_cache_key_lock) + raise AppApiException(1004, + _("The email service has not been set up. Please contact the administrator to set up the email service in [Email Settings].")) + try: + connection = EmailBackend(system_setting.meta.get("email_host"), + system_setting.meta.get('email_port'), + system_setting.meta.get('email_host_user'), + system_setting.meta.get('email_host_password'), + system_setting.meta.get('email_use_tls'), + False, + system_setting.meta.get('email_use_ssl') + ) + # 发送邮件 + send_mail(_('【Intelligent knowledge base question and answer system-{action}】').format( + action=_('User registration') if state == 'register' else _('Change password')), + '', + html_message=f'{content.replace("${code}", code)}', + from_email=system_setting.meta.get('from_email'), + recipient_list=[email], fail_silently=False, connection=connection) + except Exception as e: + # user_cache.delete(code_cache_key_lock) + raise AppApiException(500, f"{str(e)}" + _("Email sending failed")) + # user_cache.set(code_cache_key, code, timeout=datetime.timedelta(minutes=30)) + return True + + +class CheckCodeSerializer(serializers.Serializer): + """ + 校验验证码 + """ + email = serializers.EmailField( + required=True, + label=_("Email"), + validators=[validators.EmailValidator(message=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.message, + code=ExceptionCodeConstants.EMAIL_FORMAT_ERROR.value.code)]) + code = serializers.CharField(required=True, label=_("Verification code")) + + type = serializers.CharField(required=True, + label=_("Type"), + validators=[ + validators.RegexValidator(regex=re.compile("^register|reset_password$"), + message=_( + "The type only supports register|reset_password"), + code=500) + ]) + + def is_valid(self, *, raise_exception=False): + super().is_valid() + #TODO 这里的缓存 需要重新设计 + value = None#user_cache.get(self.data.get("email") + ":" + self.data.get("type")) + if value is None or value != self.data.get("code"): + raise ExceptionCodeConstants.CODE_ERROR.value.to_app_api_exception() + return True diff --git a/apps/users/urls.py b/apps/users/urls.py index 68c641659..636fcbec0 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -8,6 +8,11 @@ urlpatterns = [ path('user/profile', views.UserProfileView.as_view(), name="user_profile"), path('user/captcha', views.CaptchaView.as_view(), name='captcha'), path('user/test', views.TestPermissionsUserView.as_view(), name="test"), + path('user/logout', views.Logout.as_view(), name='logout'), + path("user/send_email", views.SendEmail.as_view(), name='send_email'), + path("user/check_code", views.CheckCode.as_view(), name='check_code'), + path("user/re_password", views.RePasswordView.as_view(), name='re_password'), + path("user/current/send_email", views.SendEmailToCurrentUserView.as_view(), name="send_email_current"), path('workspace//user_list', views.WorkspaceUserListView.as_view(), name="test_workspace_id_permission"), path('workspace//user/profile', views.TestWorkspacePermissionUserView.as_view(), diff --git a/apps/users/views/login.py b/apps/users/views/login.py index c34b21512..f8c7b1c51 100644 --- a/apps/users/views/login.py +++ b/apps/users/views/login.py @@ -6,14 +6,18 @@ @date:2025/4/14 10:22 @desc: """ +from django.core.cache import cache from django.utils.translation import gettext_lazy as _ from drf_spectacular.utils import extend_schema from rest_framework.request import Request from rest_framework.views import APIView from common import result +from common.auth import TokenAuth +from common.constants.cache_version import Cache_Version from common.log.log import log from common.utils.common import encryption +from models_provider.api.model import DefaultModelResponse from users.api.login import LoginAPI, CaptchaAPI from users.serializers.login import LoginSerializer, CaptchaSerializer @@ -44,6 +48,23 @@ class LoginView(APIView): return result.success(LoginSerializer().login(request.data)) +class Logout(APIView): + authentication_classes = [TokenAuth] + + @extend_schema(methods=['POST'], + summary=_("Sign out"), + description=_("Sign out"), + operation_id=_("Sign out"), # type: ignore + tags=[_("User Management")], # type: ignore + responses=DefaultModelResponse.get_response()) + @log(menu='User management', operate='Sign out', + get_operation_object=lambda r, k: {'name': r.user.username}) + def post(self, request: Request): + version, get_key = Cache_Version.TOKEN.value + cache.delete(get_key(token=request.auth), version=version) + return result.success(True) + + class CaptchaView(APIView): @extend_schema(methods=['GET'], summary=_("Get captcha"), diff --git a/apps/users/views/user.py b/apps/users/views/user.py index e12b69810..b92e3b3f9 100644 --- a/apps/users/views/user.py +++ b/apps/users/views/user.py @@ -20,10 +20,12 @@ from common.result import result from maxkb.const import CONFIG from models_provider.api.model import DefaultModelResponse from tools.serializers.tool import encryption +from users.api import SendEmailAPI, CheckCodeAPI, ResetPasswordAPI from users.api.user import UserProfileAPI, TestWorkspacePermissionUserApi, DeleteUserApi, EditUserApi, \ ChangeUserPasswordApi, UserPageApi, UserListApi, UserPasswordResponse, WorkspaceUserAPI from users.models import User -from users.serializers.user import UserProfileSerializer, UserManageSerializer +from users.serializers.user import UserProfileSerializer, UserManageSerializer, CheckCodeSerializer, \ + SendEmailSerializer, RePasswordSerializer default_password = CONFIG.get('default_password', 'MaxKB@123..') @@ -223,3 +225,73 @@ class UserManage(APIView): data={'email_or_username': request.query_params.get('email_or_username', None), 'user_id': str(request.user.id)}) return result.success(d.page(current_page, page_size)) + + +class RePasswordView(APIView): + + @extend_schema(methods=['POST'], + summary=_("Change password"), + description=_("Change password"), + operation_id=_("Change password"), # type: ignore + tags=[_("User Management")], # type: ignore + request=ResetPasswordAPI.get_request(), + responses=DefaultModelResponse.get_response()) + @log(menu='User management', operate='Change password', + get_operation_object=lambda r, k: {'name': r.data.get('email', None)}, + get_user=lambda r: {'user_name': None, 'email': r.data.get('email', None)}, + get_details=get_re_password_details) + def post(self, request: Request): + serializer_obj = RePasswordSerializer(data=request.data) + return result.success(serializer_obj.reset_password()) + + +class SendEmail(APIView): + + @extend_schema(methods=['POST'], + summary=_("Send email"), + description=_("Send email"), + operation_id=_("Send email"), # type: ignore + tags=[_("User Management")], # type: ignore + request=SendEmailAPI().get_request(), + responses=SendEmailAPI().get_response()) + @log(menu='User management', operate='Send email', + get_operation_object=lambda r, k: {'name': r.data.get('email', None)}, + get_user=lambda r: {'user_name': None, 'email': r.data.get('email', None)}) + def post(self, request: Request): + serializer_obj = SendEmailSerializer(data=request.data) + if serializer_obj.is_valid(raise_exception=True): + return result.success(serializer_obj.send()) + + +class CheckCode(APIView): + + @extend_schema(methods=['POST'], + summary=_("Check whether the verification code is correct"), + description=_("Check whether the verification code is correct"), + operation_id=_("Check whether the verification code is correct"), # type: ignore + tags=[_("User Management")], # type: ignore + request=CheckCodeAPI().get_request(), + responses=CheckCodeAPI().get_response()) + @log(menu='User management', operate='Check whether the verification code is correct', + get_operation_object=lambda r, k: {'name': r.data.get('email', None)}, + get_user=lambda r: {'user_name': None, 'email': r.data.get('email', None)}) + def post(self, request: Request): + return result.success(CheckCodeSerializer(data=request.data).is_valid(raise_exception=True)) + + +class SendEmailToCurrentUserView(APIView): + authentication_classes = [TokenAuth] + + @extend_schema(methods=['POST'], + summary=_("Send email to current user"), + description=_("Send email to current user"), + operation_id=_("Send email to current user"), # type: ignore + tags=[_("User Management")], # type: ignore + request=SendEmailAPI().get_request(), + responses=SendEmailAPI().get_response()) + @log(menu='User management', operate='Send email to current user', + get_operation_object=lambda r, k: {'name': r.user.username}) + def post(self, request: Request): + serializer_obj = SendEmailSerializer(data={'email': request.user.email, 'type': "reset_password"}) + if serializer_obj.is_valid(raise_exception=True): + return result.success(serializer_obj.send()) diff --git a/ui/src/api/system-api-key.ts b/ui/src/api/system-api-key.ts new file mode 100644 index 000000000..6c7a9091a --- /dev/null +++ b/ui/src/api/system-api-key.ts @@ -0,0 +1,58 @@ +import {Result} from '@/request/Result' +import {get, post, del, put} from '@/request/index' + +import {type Ref} from 'vue' + +const prefix = '/system/api_key' + +/** + * API_KEY列表 + */ +const getAPIKey: (loading?: Ref) => Promise> = () => { + return get(`${prefix}`) +} + +/** + * 新增API_KEY + */ +const postAPIKey: (loading?: Ref) => Promise> = ( + loading +) => { + return post(`${prefix}`, {}, undefined, loading) +} + +/** + * 删除API_KEY + * @param 参数 application_id api_key_id + */ +const delAPIKey: ( + api_key_id: string, + loading?: Ref +) => Promise> = (api_key_id, loading) => { + return del(`${prefix}/${api_key_id}`, undefined, undefined, loading) +} + +/** + * 修改API_KEY + * data { + * is_active: boolean + * } + * @param api_key_id + * @param data + * @param loading + */ +const putAPIKey: ( + api_key_id: string, + data: any, + loading?: Ref +) => Promise> = (api_key_id, data, loading) => { + return put(`${prefix}/${api_key_id}`, data, undefined, loading) +} + + +export default { + getAPIKey, + postAPIKey, + delAPIKey, + putAPIKey +} diff --git a/ui/src/api/user/user-manage.ts b/ui/src/api/user/user-manage.ts index edd15a1b1..e8e869210 100644 --- a/ui/src/api/user/user-manage.ts +++ b/ui/src/api/user/user-manage.ts @@ -2,6 +2,7 @@ import { Result } from '@/request/Result' import { get, put, post, del } from '@/request/index' import type { pageRequest } from '@/api/type/common' import type { Ref } from 'vue' +import type {ResetPasswordRequest} from "@/api/type/user.ts"; const prefix = '/user_manage' /** @@ -64,11 +65,24 @@ const putUserManagePassword: ( return put(`${prefix}/${user_id}/re_password`, data, undefined, loading) } +/** + * 重置密码 + * @param request 重置密码请求参数 + * @param loading 接口加载器 + * @returns + */ +const resetPassword: ( + request: ResetPasswordRequest, + loading?: Ref +) => Promise> = (request, loading) => { + return post('/user/re_password', request, undefined, loading) +} export default { getUserManage, putUserManage, delUserManage, postUserManage, - putUserManagePassword + putUserManagePassword, + resetPassword } diff --git a/ui/src/layout/layout-header/avatar/index.vue b/ui/src/layout/layout-header/avatar/index.vue index c8e86c5b3..6b058cfc0 100644 --- a/ui/src/layout/layout-header/avatar/index.vue +++ b/ui/src/layout/layout-header/avatar/index.vue @@ -87,9 +87,10 @@ + + -->