From c1ddec1a610f7f3546c3efb08f95e4c460d04838 Mon Sep 17 00:00:00 2001 From: shaohuzhang1 <80892890+shaohuzhang1@users.noreply.github.com> Date: Tue, 20 May 2025 20:16:36 +0800 Subject: [PATCH] feat: Login and add graphic captcha (#3117) --- apps/common/util/common.py | 8 ++++ apps/locales/en_US/LC_MESSAGES/django.po | 9 +++++ apps/locales/zh_CN/LC_MESSAGES/django.po | 11 +++++- apps/locales/zh_Hant/LC_MESSAGES/django.po | 11 +++++- apps/smartdoc/settings/base.py | 4 ++ apps/users/serializers/user_serializers.py | 39 +++++++++++++++++-- apps/users/urls.py | 1 + apps/users/views/user.py | 14 ++++++- pyproject.toml | 1 + ui/src/api/type/user.ts | 4 ++ ui/src/api/user.ts | 10 ++++- ui/src/locales/lang/en-US/views/user.ts | 4 ++ ui/src/locales/lang/zh-CN/views/user.ts | 10 +++-- ui/src/locales/lang/zh-Hant/views/user.ts | 4 ++ ui/src/stores/modules/user.ts | 4 +- ui/src/views/login/index.vue | 44 ++++++++++++++++++++-- 16 files changed, 161 insertions(+), 17 deletions(-) diff --git a/apps/common/util/common.py b/apps/common/util/common.py index b0111029a..6a1253e9b 100644 --- a/apps/common/util/common.py +++ b/apps/common/util/common.py @@ -11,6 +11,7 @@ import importlib import io import mimetypes import pickle +import random import re import shutil from functools import reduce @@ -297,3 +298,10 @@ def markdown_to_plain_text(md: str) -> str: # 去除首尾空格 text = text.strip() return text + + +CHAR_SET = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] + + +def get_random_chars(number=6): + return "".join([CHAR_SET[random.randint(0, len(CHAR_SET) - 1)] for index in range(number)]) diff --git a/apps/locales/en_US/LC_MESSAGES/django.po b/apps/locales/en_US/LC_MESSAGES/django.po index d13912928..e068ff410 100644 --- a/apps/locales/en_US/LC_MESSAGES/django.po +++ b/apps/locales/en_US/LC_MESSAGES/django.po @@ -7490,4 +7490,13 @@ msgid "Field: {name} No value set" msgstr "" msgid "Generate related" +msgstr "" + +msgid "Obtain graphical captcha" +msgstr "" + +msgid "Captcha code error or expiration" +msgstr "" + +msgid "captcha" msgstr "" \ No newline at end of file diff --git a/apps/locales/zh_CN/LC_MESSAGES/django.po b/apps/locales/zh_CN/LC_MESSAGES/django.po index 75fec7099..346fd5e47 100644 --- a/apps/locales/zh_CN/LC_MESSAGES/django.po +++ b/apps/locales/zh_CN/LC_MESSAGES/django.po @@ -7653,4 +7653,13 @@ msgid "Field: {name} No value set" msgstr "字段: {name} 未设置值" msgid "Generate related" -msgstr "生成问题" \ No newline at end of file +msgstr "生成问题" + +msgid "Obtain graphical captcha" +msgstr "获取图形验证码" + +msgid "Captcha code error or expiration" +msgstr "验证码错误或过期" + +msgid "captcha" +msgstr "验证码" \ No newline at end of file diff --git a/apps/locales/zh_Hant/LC_MESSAGES/django.po b/apps/locales/zh_Hant/LC_MESSAGES/django.po index 870f282ae..8bf746a89 100644 --- a/apps/locales/zh_Hant/LC_MESSAGES/django.po +++ b/apps/locales/zh_Hant/LC_MESSAGES/django.po @@ -7663,4 +7663,13 @@ msgid "Field: {name} No value set" msgstr "欄位: {name} 未設定值" msgid "Generate related" -msgstr "生成問題" \ No newline at end of file +msgstr "生成問題" + +msgid "Obtain graphical captcha" +msgstr "獲取圖形驗證碼" + +msgid "Captcha code error or expiration" +msgstr "驗證碼錯誤或過期" + +msgid "captcha" +msgstr "驗證碼" \ No newline at end of file diff --git a/apps/smartdoc/settings/base.py b/apps/smartdoc/settings/base.py index edf458662..de8142079 100644 --- a/apps/smartdoc/settings/base.py +++ b/apps/smartdoc/settings/base.py @@ -126,6 +126,10 @@ CACHES = { "token_cache": { 'BACKEND': 'common.cache.file_cache.FileCache', 'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "token_cache") # 文件夹路径 + }, + 'captcha_cache': { + 'BACKEND': 'common.cache.file_cache.FileCache', + 'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "captcha_cache") # 文件夹路径 } } diff --git a/apps/users/serializers/user_serializers.py b/apps/users/serializers/user_serializers.py index 6093819a4..0ddbc404e 100644 --- a/apps/users/serializers/user_serializers.py +++ b/apps/users/serializers/user_serializers.py @@ -6,18 +6,22 @@ @date:2023/9/5 16:32 @desc: """ +import base64 import datetime import os import random import re import uuid +from captcha.image import ImageCaptcha from django.conf import settings from django.core import validators, signing, cache from django.core.mail import send_mail from django.core.mail.backends.smtp import EmailBackend from django.db import transaction from django.db.models import Q, QuerySet, Prefetch +from django.utils.translation import get_language +from django.utils.translation import gettext_lazy as _, to_locale from drf_yasg import openapi from rest_framework import serializers @@ -30,7 +34,7 @@ from common.exception.app_exception import AppApiException from common.mixins.api_mixin import ApiMixin from common.models.db_model_manage import DBModelManage from common.response.result import get_api_response -from common.util.common import valid_license +from common.util.common import valid_license, get_random_chars from common.util.field_message import ErrMessage from common.util.lock import lock from dataset.models import DataSet, Document, Paragraph, Problem, ProblemParagraphMapping @@ -39,9 +43,29 @@ from function_lib.models.function import FunctionLib from setting.models import Team, SystemSetting, SettingType, Model, TeamMember, TeamMemberPermission from smartdoc.conf import PROJECT_DIR from users.models.user import User, password_encrypt, get_user_dynamics_permission -from django.utils.translation import gettext_lazy as _, gettext, to_locale -from django.utils.translation import get_language + user_cache = cache.caches['user_cache'] +captcha_cache = cache.caches['captcha_cache'] + + +class CaptchaSerializer(ApiMixin, serializers.Serializer): + @staticmethod + def get_response_body_api(): + return get_api_response(openapi.Schema( + type=openapi.TYPE_STRING, + title="captcha", + default="xxxx", + description="captcha" + )) + + @staticmethod + def generate(): + chars = get_random_chars() + image = ImageCaptcha() + data = image.generate(chars) + captcha = base64.b64encode(data.getbuffer()) + captcha_cache.set(f"LOGIN:{chars}", chars, timeout=5 * 60) + return 'data:image/png;base64,' + captcha.decode() class SystemSerializer(ApiMixin, serializers.Serializer): @@ -71,6 +95,8 @@ class LoginSerializer(ApiMixin, serializers.Serializer): password = serializers.CharField(required=True, error_messages=ErrMessage.char(_("Password"))) + captcha = serializers.CharField(required=True, error_messages=ErrMessage.char(_("captcha"))) + def is_valid(self, *, raise_exception=False): """ 校验参数 @@ -78,6 +104,10 @@ class LoginSerializer(ApiMixin, serializers.Serializer): :return: User information """ super().is_valid(raise_exception=True) + captcha = self.data.get('captcha') + captcha_value = captcha_cache.get(f"LOGIN:{captcha}") + if captcha_value is None: + raise AppApiException(1005, _("Captcha code error or expiration")) username = self.data.get("username") password = password_encrypt(self.data.get("password")) user = QuerySet(User).filter(Q(username=username, @@ -109,7 +139,8 @@ class LoginSerializer(ApiMixin, serializers.Serializer): required=['username', 'password'], properties={ 'username': openapi.Schema(type=openapi.TYPE_STRING, title=_("Username"), description=_("Username")), - 'password': openapi.Schema(type=openapi.TYPE_STRING, title=_("Password"), description=_("Password")) + 'password': openapi.Schema(type=openapi.TYPE_STRING, title=_("Password"), description=_("Password")), + 'captcha': openapi.Schema(type=openapi.TYPE_STRING, title=_("captcha"), description=_("captcha")) } ) diff --git a/apps/users/urls.py b/apps/users/urls.py index e5e2fe0df..a9d1e134c 100644 --- a/apps/users/urls.py +++ b/apps/users/urls.py @@ -6,6 +6,7 @@ app_name = "user" urlpatterns = [ path('profile', views.Profile.as_view()), path('user', views.User.as_view(), name="profile"), + path('user/captcha', views.CaptchaView.as_view(), name='captcha'), path('user/language', views.SwitchUserLanguageView.as_view(), name='language'), path('user/list', views.User.Query.as_view()), path('user/login', views.Login.as_view(), name='login'), diff --git a/apps/users/views/user.py b/apps/users/views/user.py index 55d4b6b9a..9e21daa4e 100644 --- a/apps/users/views/user.py +++ b/apps/users/views/user.py @@ -26,7 +26,7 @@ from smartdoc.settings import JWT_AUTH from users.serializers.user_serializers import RegisterSerializer, LoginSerializer, CheckCodeSerializer, \ RePasswordSerializer, \ SendEmailSerializer, UserProfile, UserSerializer, UserManageSerializer, UserInstanceSerializer, SystemSerializer, \ - SwitchLanguageSerializer + SwitchLanguageSerializer, CaptchaSerializer from users.views.common import get_user_operation_object, get_re_password_details user_cache = cache.caches['user_cache'] @@ -170,6 +170,18 @@ def _get_details(request): } +class CaptchaView(APIView): + + @action(methods=['GET'], detail=False) + @swagger_auto_schema(operation_summary=_("Obtain graphical captcha"), + operation_id=_("Obtain graphical captcha"), + responses=CaptchaSerializer().get_response_body_api(), + security=[], + tags=[_("User management")]) + def get(self, request: Request): + return result.success(CaptchaSerializer().generate()) + + class Login(APIView): @action(methods=['POST'], detail=False) diff --git a/pyproject.toml b/pyproject.toml index bddbe0202..ea87b807d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ django-db-connection-pool = "1.2.5" opencv-python-headless = "4.11.0.86" pymysql = "1.1.1" accelerate = "1.6.0" +captcha = "0.7.1" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/ui/src/api/type/user.ts b/ui/src/api/type/user.ts index a45267354..ef22b55d9 100644 --- a/ui/src/api/type/user.ts +++ b/ui/src/api/type/user.ts @@ -37,6 +37,10 @@ interface LoginRequest { * 密码 */ password: string + /** + * 验证码 + */ + captcha: string } interface RegisterRequest { diff --git a/ui/src/api/user.ts b/ui/src/api/user.ts index eb12fd2eb..d14a65e2f 100644 --- a/ui/src/api/user.ts +++ b/ui/src/api/user.ts @@ -27,6 +27,13 @@ const login: ( } return post('/user/login', request, undefined, loading) } +/** + * 获取图形验证码 + * @returns + */ +const getCaptcha: () => Promise> = () => { + return get('user/captcha') +} /** * 登出 * @param loading 接口加载器 @@ -226,5 +233,6 @@ export default { postLanguage, getDingOauth2Callback, getlarkCallback, - getQrSource + getQrSource, + getCaptcha } diff --git a/ui/src/locales/lang/en-US/views/user.ts b/ui/src/locales/lang/en-US/views/user.ts index ae41fd564..2bbc14043 100644 --- a/ui/src/locales/lang/en-US/views/user.ts +++ b/ui/src/locales/lang/en-US/views/user.ts @@ -28,6 +28,10 @@ export default { requiredMessage: 'Please enter username', lengthMessage: 'Length must be between 6 and 20 words' }, + captcha: { + label: 'captcha', + placeholder: 'Please enter the captcha' + }, nick_name: { label: 'Name', placeholder: 'Please enter name' diff --git a/ui/src/locales/lang/zh-CN/views/user.ts b/ui/src/locales/lang/zh-CN/views/user.ts index 4e2a8760f..191074c0c 100644 --- a/ui/src/locales/lang/zh-CN/views/user.ts +++ b/ui/src/locales/lang/zh-CN/views/user.ts @@ -25,6 +25,10 @@ export default { requiredMessage: '请输入用户名', lengthMessage: '长度在 6 到 20 个字符' }, + captcha: { + label: '验证码', + placeholder: '请输入验证码' + }, nick_name: { label: '姓名', placeholder: '请输入姓名' @@ -33,7 +37,7 @@ export default { label: '邮箱', placeholder: '请输入邮箱', requiredMessage: '请输入邮箱', - validatorEmail: '请输入有效邮箱格式!', + validatorEmail: '请输入有效邮箱格式!' }, phone: { label: '手机号', @@ -48,13 +52,13 @@ export default { new_password: { label: '新密码', placeholder: '请输入新密码', - requiredMessage: '请输入新密码', + requiredMessage: '请输入新密码' }, re_password: { label: '确认密码', placeholder: '请输入确认密码', requiredMessage: '请输入确认密码', - validatorMessage: '密码不一致', + validatorMessage: '密码不一致' } } }, diff --git a/ui/src/locales/lang/zh-Hant/views/user.ts b/ui/src/locales/lang/zh-Hant/views/user.ts index 18ea3326a..7b8f1a880 100644 --- a/ui/src/locales/lang/zh-Hant/views/user.ts +++ b/ui/src/locales/lang/zh-Hant/views/user.ts @@ -26,6 +26,10 @@ export default { requiredMessage: '請輸入使用者名稱', lengthMessage: '長度須介於 6 到 20 個字元之間' }, + captcha: { + label: '驗證碼', + placeholder: '請輸入驗證碼' + }, nick_name: { label: '姓名', placeholder: '請輸入姓名' diff --git a/ui/src/stores/modules/user.ts b/ui/src/stores/modules/user.ts index bfd769ba2..a5f0eae3a 100644 --- a/ui/src/stores/modules/user.ts +++ b/ui/src/stores/modules/user.ts @@ -135,8 +135,8 @@ const useUserStore = defineStore({ }) }, - async login(auth_type: string, username: string, password: string) { - return UserApi.login(auth_type, { username, password }).then((ok) => { + async login(auth_type: string, username: string, password: string, captcha: string) { + return UserApi.login(auth_type, { username, password, captcha }).then((ok) => { this.token = ok.data localStorage.setItem('token', ok.data) return this.profile() diff --git a/ui/src/views/login/index.vue b/ui/src/views/login/index.vue index 714c439c6..3c8621d73 100644 --- a/ui/src/views/login/index.vue +++ b/ui/src/views/login/index.vue @@ -34,6 +34,21 @@ +
+ +
+ + + + +
+
+
({ username: '', - password: '' + password: '', + captcha: '' }) - +const identifyCode = ref('') +function makeCode() { + useApi.getCaptcha().then((res: any) => { + identifyCode.value = res.data + }) +} const rules = ref>({ username: [ { @@ -137,6 +159,13 @@ const rules = ref>({ message: t('views.user.userForm.form.password.requiredMessage'), trigger: 'blur' } + ], + captcha: [ + { + required: true, + message: t('views.user.userForm.form.captcha.placeholder'), + trigger: 'blur' + } ] }) const loginFormRef = ref() @@ -222,7 +251,8 @@ function changeMode(val: string) { showQrCodeTab.value = false loginForm.value = { username: '', - password: '' + password: '', + captcha: '' } redirectAuth(val) loginFormRef.value?.clearValidate() @@ -232,7 +262,12 @@ const login = () => { loginFormRef.value?.validate().then(() => { loading.value = true user - .login(loginMode.value, loginForm.value.username, loginForm.value.password) + .login( + loginMode.value, + loginForm.value.username, + loginForm.value.password, + loginForm.value.captcha + ) .then(() => { locale.value = localStorage.getItem('MaxKB-locale') || getBrowserLang() || 'en-US' router.push({ name: 'home' }) @@ -285,6 +320,7 @@ onBeforeMount(() => { declare const window: any onMounted(() => { + makeCode() const route = useRoute() const currentUrl = ref(route.fullPath) const params = new URLSearchParams(currentUrl.value.split('?')[1])