diff --git a/apps/chat/serializers/chat_user_serializer.py b/apps/chat/serializers/chat_user_serializer.py new file mode 100644 index 00000000000..a0162f863e8 --- /dev/null +++ b/apps/chat/serializers/chat_user_serializer.py @@ -0,0 +1,101 @@ +import json + +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers + +from application.models import ApplicationAccessToken +from common.auth.common import ChatToken, FileToken +from common.auth.constants.operate_constants import Operate +from common.constants.authentication_type import AuthenticationType +from common.constants.cache_version import Cache_Version +from common.exception.app_exception import AppApiException +from common.utils.common import password_encrypt +from common.utils.common import password_verify, needs_password_upgrade +from common.utils.rsa_util import decrypt +from system_manage.models import ChatUser +from users.serializers.login import LoginRequest + +system_version, system_get_key = Cache_Version.SYSTEM.value + + +class ChatUserAccessTokenV3Serializer(serializers.Serializer): + + @staticmethod + def create_token_and_cache(user, request): + _type = AuthenticationType.CHAT_USER + token = ChatToken(str(user.id),_type, str(Operate.LOCAL)).to_token() + return token, FileToken(str(user.id),_type).to_token() + + @staticmethod + def get_auth_setting(): + application_access_token = ApplicationAccessToken.objects.filter( + is_active=True + ).first() + + if not application_access_token: + raise AppApiException(1005, _('Invalid access token')) + + return application_access_token.authentication_value + + @staticmethod + def local_login(instance): + username = instance.get("username", "") + encryptedData = instance.get("encryptedData", "") + if encryptedData: + json_data = json.loads(decrypt(encryptedData)) + instance.update(json_data) + try: + LoginRequest(data=instance).is_valid(raise_exception=True) + except Exception as e: + raise e + auth_setting = ChatUserAccessTokenV3Serializer.get_auth_setting() + + max_attempts = auth_setting.get("max_attempts", 1) + password = instance.get("password") + captcha = instance.get("captcha", "") + + # 判断是否需要验证码 + need_captcha = True + if max_attempts == -1: + need_captcha = False + elif max_attempts > 0: + fail_count = cache.get(system_get_key(f'chat_{username}'), version=system_version) or 0 + need_captcha = fail_count >= max_attempts + + if need_captcha: + if not captcha: + raise AppApiException(1005, _("Captcha is required")) + + captcha_cache = cache.get( + Cache_Version.CAPTCHA.get_key(captcha=f"chat_{username}"), + version=Cache_Version.CAPTCHA.get_version() + ) + if captcha_cache is None or captcha.lower() != captcha_cache: + raise AppApiException(1005, _("Captcha code error or expiration")) + + user = ChatUser.objects.filter(username=username).first() + + if not user or not password_verify(password, user.password): + record_login_fail(username) + raise AppApiException(500, _('The username or password is incorrect')) + + if needs_password_upgrade(user.password): + user.password = password_encrypt(password) + user.save(update_fields=['password']) + if not user.is_active: + raise AppApiException(1005, _("The user has been disabled, please contact the administrator!")) + cache.delete(system_get_key(f'chat_{username}'), version=system_version) + return user + + +def record_login_fail(username: str, expire: int = 600): + """记录登录失败次数""" + if not username: + return + fail_key = system_get_key(f'chat_{username}') + fail_count = cache.get(fail_key, version=system_version) + if fail_count is None: + cache.set(fail_key, 1, timeout=expire, version=system_version) + else: + cache.incr(fail_key, 1, version=system_version) diff --git a/apps/chat/urls.py b/apps/chat/urls.py index 05c07ad9188..0bababe7646 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -30,7 +30,7 @@ path('embed', v3_views.ChatEmbedView.as_view()), path('mcp', v3_views.mcp_view), path('auth/anonymous', v3_views.AnonymousAuthentication.as_view()), - path('auth/login/', v3_views.LocalLoginView.as_view()), + path('auth/login', v3_views.LocalLoginView.as_view()), path('auth/logout', v3_views.Logout.as_view(), name='v3_logout'), path('profile', v3_views.AuthProfile.as_view()), path('captcha', v3_views.CaptchaView.as_view(), name='v3_captcha'), diff --git a/apps/chat/views/v3/chat.py b/apps/chat/views/v3/chat.py index 0a3cf694c70..a736c7ef7c0 100644 --- a/apps/chat/views/v3/chat.py +++ b/apps/chat/views/v3/chat.py @@ -43,7 +43,7 @@ from models_provider.api.model import DefaultModelResponse from oss.serializers.file import FileSerializer from system_manage.serializers.chat_user import RePasswordSerializer, ChatUserProfileSerializer -from system_manage.serializers.chat_user_serializer import ChatUserAccessTokenSerializer +from chat.serializers.chat_user_serializer import ChatUserAccessTokenV3Serializer from users.api import CaptchaAPI, LoginAPI from users.api.user import ResetPasswordAPI, UserProfileAPI from users.serializers.login import CaptchaSerializer @@ -135,7 +135,7 @@ def post(self, request: Request): key="mk_file_auth", value=f_token, max_age=7 * 24 * 3600, - path=f'{CONFIG.get_chat_path()}/{request.data.get("access_token")}', + path=CONFIG.get_chat_path(), secure=is_https, httponly=True, samesite="None" if is_https else "Lax", @@ -263,8 +263,8 @@ class CaptchaView(APIView): responses=CaptchaAPI.get_response()) def get(self, request: Request): username = request.query_params.get('username', None) - accessToken = request.query_params.get('accessToken', None) - return result.success(CaptchaSerializer().chat_generate(username, 'chat', accessToken)) + application_id = request.query_params.get('application_id', None) + return result.success(CaptchaSerializer().chat_generate(username, 'chat', application_id)) class SpeechToText(APIView): @@ -383,13 +383,13 @@ class ChatUserProfileView(APIView): responses=UserProfileAPI.get_response(), ) def get(self, request: Request): - return result.success(ChatUserProfileSerializer().profile(request.user)) + return result.success(ChatUserProfileSerializer().profile(request.user.profile)) class BaseAuthView(APIView): @staticmethod - def create_token_and_cache(access_token, user, request): - token = ChatUserAccessTokenSerializer.create_token_and_cache(access_token, user, request) + def create_token_and_cache(user, request): + token, f_token = ChatUserAccessTokenV3Serializer.create_token_and_cache(user, request) version, get_key = Cache_Version.CHAT_USER_TOKEN.value cache.set(get_key(token), user, timeout=60 * 60 * 2, version=version) return token, FileToken(str(user.id), AuthenticationType.CHAT_USER.value).to_token() @@ -420,12 +420,12 @@ class LocalLoginView(BaseAuthView): request=LoginAPI.get_request(), responses=LoginAPI.get_response(), ) - def post(self, request: Request, access_token: str = None): - user = ChatUserAccessTokenSerializer.local_login(request.data, access_token) + def post(self, request: Request): + user = ChatUserAccessTokenV3Serializer.local_login(request.data) user.source = "LOCAL" - token, f_token = self.create_token_and_cache(access_token, user, request) + token, f_token = self.create_token_and_cache(user, request) response = result.success({'token': token}) - return self.generate(request, f_token, response, path=f'/chat/{access_token}/') + return self.generate(request, f_token, response, path=f'/chat/') class Logout(APIView): diff --git a/apps/common/auth/handle/impl/chat_user_token.py b/apps/common/auth/handle/impl/chat_user_token.py index b3f9a6ebd98..e9c5c8c0eea 100644 --- a/apps/common/auth/handle/impl/chat_user_token.py +++ b/apps/common/auth/handle/impl/chat_user_token.py @@ -20,7 +20,7 @@ from common.constants.authentication_type import AuthenticationType from common.exception.app_exception import AppUnauthorizedFailed from system_manage.models import ResourceChatUserGroupAuthorize, ResourceType, ResourceChatUserAuthorize, \ - UserGroupRelation + UserGroupRelation, ChatUser login_type_list = [Operate.LOCAL.value, Operate.CAS.value, Operate.DINGTALK.value, Operate.WECOM.value, Operate.LARK.value, Operate.OIDC.value, Operate.LDAP.value, @@ -41,6 +41,7 @@ def handle(self, request, token: str, get_token_details): ) _type = ChatUserType.ANONYMOUS_USER login_type = auth_details.get('login_type') + user_id = auth_details.get('user_id') application_id = (auth_details.get('kwargs') or {}).get('application_id') if login_type.upper() == str(Operate.ANNOTATION_AUTH): application_access_token_list = application_access_token_list.filter(authentication=False) @@ -50,7 +51,6 @@ def handle(self, request, token: str, get_token_details): elif login_type_list.__contains__(login_type.upper()): _type = ChatUserType.CHAT_USER - user_id = auth_details.get('id') user_group_ids = QuerySet(UserGroupRelation).filter( user_id=user_id, ).values_list('group_id', flat=True) @@ -92,11 +92,13 @@ def handle(self, request, token: str, get_token_details): permission_list.append(ChatPermissionConstants.CHAT_USER_ANONYMOUS.value) k = f"{Group.CHAT_USER}:r:{application_access_token.application_id}" permissions[k] = reduce(lambda x, y: x | y, [p.bit() for p in permission_list], 0) + chat_user = QuerySet(ChatUser).filter(id=user_id).first() if application_id: # 指定了 application_id(v2 流程)时,直接校验该应用是否有权限,无权限直接抛错, # 避免返回一个空权限的 Principal 造成静默失败。 if not permissions.get(f"{Group.CHAT_USER}:r:{application_id}"): raise AppUnauthorizedFailed(403, _('No permission to access')) - return Principal(auth_details.get('user_id'), _type, application_id=application_id), Auth(set(), - permissions) - return Principal(auth_details.get('user_id'), _type), Auth(set(), permissions) + return Principal(auth_details.get('user_id'), _type, application_id=application_id, + profile=chat_user), Auth(set(), + permissions) + return Principal(auth_details.get('user_id'), _type, profile=chat_user), Auth(set(), permissions)