feat: Login and add graphic captcha (#3117)
Some checks are pending
sync2gitee / repo-sync (push) Waiting to run
Typos Check / Spell Check with Typos (push) Waiting to run

This commit is contained in:
shaohuzhang1 2025-05-20 20:16:36 +08:00 committed by GitHub
parent 1ba8077e95
commit c1ddec1a61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 161 additions and 17 deletions

View File

@ -11,6 +11,7 @@ import importlib
import io import io
import mimetypes import mimetypes
import pickle import pickle
import random
import re import re
import shutil import shutil
from functools import reduce from functools import reduce
@ -297,3 +298,10 @@ def markdown_to_plain_text(md: str) -> str:
# 去除首尾空格 # 去除首尾空格
text = text.strip() text = text.strip()
return text 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)])

View File

@ -7490,4 +7490,13 @@ msgid "Field: {name} No value set"
msgstr "" msgstr ""
msgid "Generate related" msgid "Generate related"
msgstr ""
msgid "Obtain graphical captcha"
msgstr ""
msgid "Captcha code error or expiration"
msgstr ""
msgid "captcha"
msgstr "" msgstr ""

View File

@ -7653,4 +7653,13 @@ msgid "Field: {name} No value set"
msgstr "字段: {name} 未设置值" msgstr "字段: {name} 未设置值"
msgid "Generate related" msgid "Generate related"
msgstr "生成问题" msgstr "生成问题"
msgid "Obtain graphical captcha"
msgstr "获取图形验证码"
msgid "Captcha code error or expiration"
msgstr "验证码错误或过期"
msgid "captcha"
msgstr "验证码"

View File

@ -7663,4 +7663,13 @@ msgid "Field: {name} No value set"
msgstr "欄位: {name} 未設定值" msgstr "欄位: {name} 未設定值"
msgid "Generate related" msgid "Generate related"
msgstr "生成問題" msgstr "生成問題"
msgid "Obtain graphical captcha"
msgstr "獲取圖形驗證碼"
msgid "Captcha code error or expiration"
msgstr "驗證碼錯誤或過期"
msgid "captcha"
msgstr "驗證碼"

View File

@ -126,6 +126,10 @@ CACHES = {
"token_cache": { "token_cache": {
'BACKEND': 'common.cache.file_cache.FileCache', 'BACKEND': 'common.cache.file_cache.FileCache',
'LOCATION': os.path.join(PROJECT_DIR, 'data', 'cache', "token_cache") # 文件夹路径 '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") # 文件夹路径
} }
} }

View File

@ -6,18 +6,22 @@
@date2023/9/5 16:32 @date2023/9/5 16:32
@desc: @desc:
""" """
import base64
import datetime import datetime
import os import os
import random import random
import re import re
import uuid import uuid
from captcha.image import ImageCaptcha
from django.conf import settings from django.conf import settings
from django.core import validators, signing, cache from django.core import validators, signing, cache
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.mail.backends.smtp import EmailBackend from django.core.mail.backends.smtp import EmailBackend
from django.db import transaction from django.db import transaction
from django.db.models import Q, QuerySet, Prefetch 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 drf_yasg import openapi
from rest_framework import serializers 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.mixins.api_mixin import ApiMixin
from common.models.db_model_manage import DBModelManage from common.models.db_model_manage import DBModelManage
from common.response.result import get_api_response 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.field_message import ErrMessage
from common.util.lock import lock from common.util.lock import lock
from dataset.models import DataSet, Document, Paragraph, Problem, ProblemParagraphMapping 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 setting.models import Team, SystemSetting, SettingType, Model, TeamMember, TeamMemberPermission
from smartdoc.conf import PROJECT_DIR from smartdoc.conf import PROJECT_DIR
from users.models.user import User, password_encrypt, get_user_dynamics_permission 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'] 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): class SystemSerializer(ApiMixin, serializers.Serializer):
@ -71,6 +95,8 @@ class LoginSerializer(ApiMixin, serializers.Serializer):
password = serializers.CharField(required=True, error_messages=ErrMessage.char(_("Password"))) 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): def is_valid(self, *, raise_exception=False):
""" """
校验参数 校验参数
@ -78,6 +104,10 @@ class LoginSerializer(ApiMixin, serializers.Serializer):
:return: User information :return: User information
""" """
super().is_valid(raise_exception=True) 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") username = self.data.get("username")
password = password_encrypt(self.data.get("password")) password = password_encrypt(self.data.get("password"))
user = QuerySet(User).filter(Q(username=username, user = QuerySet(User).filter(Q(username=username,
@ -109,7 +139,8 @@ class LoginSerializer(ApiMixin, serializers.Serializer):
required=['username', 'password'], required=['username', 'password'],
properties={ properties={
'username': openapi.Schema(type=openapi.TYPE_STRING, title=_("Username"), description=_("Username")), '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"))
} }
) )

View File

@ -6,6 +6,7 @@ app_name = "user"
urlpatterns = [ urlpatterns = [
path('profile', views.Profile.as_view()), path('profile', views.Profile.as_view()),
path('user', views.User.as_view(), name="profile"), 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/language', views.SwitchUserLanguageView.as_view(), name='language'),
path('user/list', views.User.Query.as_view()), path('user/list', views.User.Query.as_view()),
path('user/login', views.Login.as_view(), name='login'), path('user/login', views.Login.as_view(), name='login'),

View File

@ -26,7 +26,7 @@ from smartdoc.settings import JWT_AUTH
from users.serializers.user_serializers import RegisterSerializer, LoginSerializer, CheckCodeSerializer, \ from users.serializers.user_serializers import RegisterSerializer, LoginSerializer, CheckCodeSerializer, \
RePasswordSerializer, \ RePasswordSerializer, \
SendEmailSerializer, UserProfile, UserSerializer, UserManageSerializer, UserInstanceSerializer, SystemSerializer, \ SendEmailSerializer, UserProfile, UserSerializer, UserManageSerializer, UserInstanceSerializer, SystemSerializer, \
SwitchLanguageSerializer SwitchLanguageSerializer, CaptchaSerializer
from users.views.common import get_user_operation_object, get_re_password_details from users.views.common import get_user_operation_object, get_re_password_details
user_cache = cache.caches['user_cache'] 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): class Login(APIView):
@action(methods=['POST'], detail=False) @action(methods=['POST'], detail=False)

View File

@ -68,6 +68,7 @@ django-db-connection-pool = "1.2.5"
opencv-python-headless = "4.11.0.86" opencv-python-headless = "4.11.0.86"
pymysql = "1.1.1" pymysql = "1.1.1"
accelerate = "1.6.0" accelerate = "1.6.0"
captcha = "0.7.1"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

View File

@ -37,6 +37,10 @@ interface LoginRequest {
* *
*/ */
password: string password: string
/**
*
*/
captcha: string
} }
interface RegisterRequest { interface RegisterRequest {

View File

@ -27,6 +27,13 @@ const login: (
} }
return post('/user/login', request, undefined, loading) return post('/user/login', request, undefined, loading)
} }
/**
*
* @returns
*/
const getCaptcha: () => Promise<Result<string>> = () => {
return get('user/captcha')
}
/** /**
* *
* @param loading * @param loading
@ -226,5 +233,6 @@ export default {
postLanguage, postLanguage,
getDingOauth2Callback, getDingOauth2Callback,
getlarkCallback, getlarkCallback,
getQrSource getQrSource,
getCaptcha
} }

View File

@ -28,6 +28,10 @@ export default {
requiredMessage: 'Please enter username', requiredMessage: 'Please enter username',
lengthMessage: 'Length must be between 6 and 20 words' lengthMessage: 'Length must be between 6 and 20 words'
}, },
captcha: {
label: 'captcha',
placeholder: 'Please enter the captcha'
},
nick_name: { nick_name: {
label: 'Name', label: 'Name',
placeholder: 'Please enter name' placeholder: 'Please enter name'

View File

@ -25,6 +25,10 @@ export default {
requiredMessage: '请输入用户名', requiredMessage: '请输入用户名',
lengthMessage: '长度在 6 到 20 个字符' lengthMessage: '长度在 6 到 20 个字符'
}, },
captcha: {
label: '验证码',
placeholder: '请输入验证码'
},
nick_name: { nick_name: {
label: '姓名', label: '姓名',
placeholder: '请输入姓名' placeholder: '请输入姓名'
@ -33,7 +37,7 @@ export default {
label: '邮箱', label: '邮箱',
placeholder: '请输入邮箱', placeholder: '请输入邮箱',
requiredMessage: '请输入邮箱', requiredMessage: '请输入邮箱',
validatorEmail: '请输入有效邮箱格式!', validatorEmail: '请输入有效邮箱格式!'
}, },
phone: { phone: {
label: '手机号', label: '手机号',
@ -48,13 +52,13 @@ export default {
new_password: { new_password: {
label: '新密码', label: '新密码',
placeholder: '请输入新密码', placeholder: '请输入新密码',
requiredMessage: '请输入新密码', requiredMessage: '请输入新密码'
}, },
re_password: { re_password: {
label: '确认密码', label: '确认密码',
placeholder: '请输入确认密码', placeholder: '请输入确认密码',
requiredMessage: '请输入确认密码', requiredMessage: '请输入确认密码',
validatorMessage: '密码不一致', validatorMessage: '密码不一致'
} }
} }
}, },

View File

@ -26,6 +26,10 @@ export default {
requiredMessage: '請輸入使用者名稱', requiredMessage: '請輸入使用者名稱',
lengthMessage: '長度須介於 6 到 20 個字元之間' lengthMessage: '長度須介於 6 到 20 個字元之間'
}, },
captcha: {
label: '驗證碼',
placeholder: '請輸入驗證碼'
},
nick_name: { nick_name: {
label: '姓名', label: '姓名',
placeholder: '請輸入姓名' placeholder: '請輸入姓名'

View File

@ -135,8 +135,8 @@ const useUserStore = defineStore({
}) })
}, },
async login(auth_type: string, username: string, password: string) { async login(auth_type: string, username: string, password: string, captcha: string) {
return UserApi.login(auth_type, { username, password }).then((ok) => { return UserApi.login(auth_type, { username, password, captcha }).then((ok) => {
this.token = ok.data this.token = ok.data
localStorage.setItem('token', ok.data) localStorage.setItem('token', ok.data)
return this.profile() return this.profile()

View File

@ -34,6 +34,21 @@
</el-input> </el-input>
</el-form-item> </el-form-item>
</div> </div>
<div class="mb-24">
<el-form-item prop="captcha">
<div class="flex-between w-full">
<el-input
size="large"
class="input-item"
v-model="loginForm.captcha"
:placeholder="$t('views.user.userForm.form.captcha.placeholder')"
>
</el-input>
<img :src="identifyCode" alt="" height="40" class="ml-8 cursor" @click="makeCode" />
</div>
</el-form-item>
</div>
</el-form> </el-form>
<el-button size="large" type="primary" class="w-full" @click="login" <el-button size="large" type="primary" class="w-full" @click="login"
@ -107,6 +122,7 @@ import { useRoute, useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import useStore from '@/stores' import useStore from '@/stores'
import authApi from '@/api/auth-setting' import authApi from '@/api/auth-setting'
import useApi from '@/api/user'
import { MsgConfirm, MsgError, MsgSuccess } from '@/utils/message' import { MsgConfirm, MsgError, MsgSuccess } from '@/utils/message'
import { t, getBrowserLang } from '@/locales' import { t, getBrowserLang } from '@/locales'
@ -120,9 +136,15 @@ const { user } = useStore()
const router = useRouter() const router = useRouter()
const loginForm = ref<LoginRequest>({ const loginForm = ref<LoginRequest>({
username: '', username: '',
password: '' password: '',
captcha: ''
}) })
const identifyCode = ref<string>('')
function makeCode() {
useApi.getCaptcha().then((res: any) => {
identifyCode.value = res.data
})
}
const rules = ref<FormRules<LoginRequest>>({ const rules = ref<FormRules<LoginRequest>>({
username: [ username: [
{ {
@ -137,6 +159,13 @@ const rules = ref<FormRules<LoginRequest>>({
message: t('views.user.userForm.form.password.requiredMessage'), message: t('views.user.userForm.form.password.requiredMessage'),
trigger: 'blur' trigger: 'blur'
} }
],
captcha: [
{
required: true,
message: t('views.user.userForm.form.captcha.placeholder'),
trigger: 'blur'
}
] ]
}) })
const loginFormRef = ref<FormInstance>() const loginFormRef = ref<FormInstance>()
@ -222,7 +251,8 @@ function changeMode(val: string) {
showQrCodeTab.value = false showQrCodeTab.value = false
loginForm.value = { loginForm.value = {
username: '', username: '',
password: '' password: '',
captcha: ''
} }
redirectAuth(val) redirectAuth(val)
loginFormRef.value?.clearValidate() loginFormRef.value?.clearValidate()
@ -232,7 +262,12 @@ const login = () => {
loginFormRef.value?.validate().then(() => { loginFormRef.value?.validate().then(() => {
loading.value = true loading.value = true
user user
.login(loginMode.value, loginForm.value.username, loginForm.value.password) .login(
loginMode.value,
loginForm.value.username,
loginForm.value.password,
loginForm.value.captcha
)
.then(() => { .then(() => {
locale.value = localStorage.getItem('MaxKB-locale') || getBrowserLang() || 'en-US' locale.value = localStorage.getItem('MaxKB-locale') || getBrowserLang() || 'en-US'
router.push({ name: 'home' }) router.push({ name: 'home' })
@ -285,6 +320,7 @@ onBeforeMount(() => {
declare const window: any declare const window: any
onMounted(() => { onMounted(() => {
makeCode()
const route = useRoute() const route = useRoute()
const currentUrl = ref(route.fullPath) const currentUrl = ref(route.fullPath)
const params = new URLSearchParams(currentUrl.value.split('?')[1]) const params = new URLSearchParams(currentUrl.value.split('?')[1])