diff --git a/apps/locales/en_US/LC_MESSAGES/django.po b/apps/locales/en_US/LC_MESSAGES/django.po index e9572068d13..d9190692ab6 100644 --- a/apps/locales/en_US/LC_MESSAGES/django.po +++ b/apps/locales/en_US/LC_MESSAGES/django.po @@ -9492,4 +9492,28 @@ msgid "enable cors" msgstr "" msgid "cors config" +msgstr "" + +msgid "Get published application list by page" +msgstr "" + +msgid "Portal login" +msgstr "" + +msgid "Invalid encrypted data" +msgstr "" + +msgid "Portal authentication is not enabled" +msgstr "" + +msgid "Portal authentication is not configured" +msgstr "" + +msgid "Portal local login is not enabled" +msgstr "" + +msgid "Get portal login info" +msgstr "" + +msgid "Portal logout" 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 0fbb5b01829..ec198726f09 100644 --- a/apps/locales/zh_CN/LC_MESSAGES/django.po +++ b/apps/locales/zh_CN/LC_MESSAGES/django.po @@ -9616,4 +9616,28 @@ msgid "enable cors" msgstr "是否开启跨域设置" msgid "cors config" -msgstr "跨域配置" \ No newline at end of file +msgstr "跨域配置" + +msgid "Get published application list by page" +msgstr "分页获取已发布应用列表" + +msgid "Portal login" +msgstr "门户登录" + +msgid "Invalid encrypted data" +msgstr "无效的加密数据" + +msgid "Portal authentication is not enabled" +msgstr "门户身份认证未开启" + +msgid "Portal authentication is not configured" +msgstr "门户身份认证未配置" + +msgid "Portal local login is not enabled" +msgstr "门户本地登录未开启" + +msgid "Get portal login info" +msgstr "获取门户登录信息" + +msgid "Portal logout" +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 faab9f85ce2..4fb4074c7f6 100644 --- a/apps/locales/zh_Hant/LC_MESSAGES/django.po +++ b/apps/locales/zh_Hant/LC_MESSAGES/django.po @@ -9615,4 +9615,28 @@ msgid "enable cors" msgstr "是否開啟跨域設置" msgid "cors config" -msgstr "跨域配置" \ No newline at end of file +msgstr "跨域配置" + +msgid "Get published application list by page" +msgstr "分頁獲取已發布應用列表" + +msgid "Portal login" +msgstr "門戶登錄" + +msgid "Invalid encrypted data" +msgstr "無效的加密數據" + +msgid "Portal authentication is not enabled" +msgstr "門戶身份認證未開啟" + +msgid "Portal authentication is not configured" +msgstr "門戶身份認證未配置" + +msgid "Portal local login is not enabled" +msgstr "門戶本地登錄未開啟" + +msgid "Get portal login info" +msgstr "獲取門戶登錄信息" + +msgid "Portal logout" +msgstr "門戶退出登錄" \ No newline at end of file diff --git a/apps/portal/api/portal.py b/apps/portal/api/portal.py index e725260e22c..f15135d537b 100644 --- a/apps/portal/api/portal.py +++ b/apps/portal/api/portal.py @@ -6,8 +6,12 @@ @date:2026/8/3 @desc: 门户API文档 """ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter + from common.mixins.api_mixin import APIMixin from common.result import DefaultResultSerializer +from users.serializers.login import LoginRequest class PortalAPI(APIMixin): @@ -41,3 +45,53 @@ def get_request(): @staticmethod def get_response(): return DefaultResultSerializer + + class Application(APIMixin): + @staticmethod + def get_parameters(): + return [ + OpenApiParameter( + name='current_page', + description='当前页码', + type=OpenApiTypes.INT, + location='path', + required=True, + ), + OpenApiParameter( + name='page_size', + description='每页数量', + type=OpenApiTypes.INT, + location='path', + required=True, + ), + OpenApiParameter( + name='name', + description='应用名称搜索', + type=OpenApiTypes.STR, + location='query', + required=False, + ), + ] + + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Login(APIMixin): + @staticmethod + def get_request(): + return LoginRequest + + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Info(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer + + class Logout(APIMixin): + @staticmethod + def get_response(): + return DefaultResultSerializer diff --git a/apps/portal/serializers/portal.py b/apps/portal/serializers/portal.py index d01d1eec9a5..e5f48b1e215 100644 --- a/apps/portal/serializers/portal.py +++ b/apps/portal/serializers/portal.py @@ -6,14 +6,33 @@ @date:2026/8/3 @desc: 门户配置序列化器 """ +import json import uuid_utils.compat as uuid -from django.db.models import QuerySet +from django.core import signing +from django.core.cache import cache +from django.db.models import Exists, OuterRef, QuerySet from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from application.models import Application +from application.models.application_access_token import ApplicationAccessToken +from common.auth.common import FileToken +from common.constants.authentication_type import AuthenticationType +from common.constants.cache_version import Cache_Version +from common.database_model_manage.database_model_manage import DatabaseModelManage +from common.db.search import page_search from common.exception.app_exception import AppApiException +from common.log.log import record_log +from common.utils.common import password_verify, needs_password_upgrade, password_encrypt +from common.utils.rsa_util import decrypt, get_key_pair_by_sql from knowledge.models import File, FileSourceType +from maxkb.const import CONFIG from portal.models import Portal +from system_manage.models.chat_user import ChatUser, ResourceChatUserAuthorize, ResourceChatUserGroupAuthorize, \ + ResourceType, UserGroupRelation +from users.serializers.login import LoginRequest + +system_version, system_get_key = Cache_Version.SYSTEM.value class PortalSerializer(serializers.Serializer): @@ -82,3 +101,256 @@ def edit(self, instance, with_valid=True): self._handle_file_field(portal, field_name, instance.get(field_name)) portal.save() return PortalSerializer.Model(portal).data + + +class ApplicationResponseSerializer(serializers.Serializer): + id = serializers.CharField(required=True) + name = serializers.CharField(required=True) + desc = serializers.CharField(required=True) + icon = serializers.CharField(required=True) + type = serializers.CharField(required=True) + dialogue_number = serializers.IntegerField(required=True) + prologue = serializers.CharField(required=True) + is_publish = serializers.BooleanField(required=True) + + +class PortalApplicationSerializer(serializers.Serializer): + + class Query(serializers.Serializer): + name = serializers.CharField(required=False, allow_blank=True, label=_('Application Name'), + help_text=_('Application name')) + + def get_query_set(self): + queryset = Application.objects.filter(is_publish=True).filter( + id__in=ApplicationAccessToken.objects.filter( + authentication=False + ).values_list('application_id', flat=True) + ) + name = self.data.get('name') + if name: + queryset = queryset.filter(name__icontains=name) + return queryset.order_by('-create_time') + + def _apply_auth_filter(self, queryset, user_id): + chat_user_exists = ChatUser.objects.filter( + id=user_id + ).exists() + + if not chat_user_exists: + return queryset + direct_auth = ResourceChatUserAuthorize.objects.filter( + resource_id=OuterRef('id'), + resource_type=ResourceType.APPLICATION.value, + is_auth=True, + user_id=user_id + ) + user_groups = UserGroupRelation.objects.filter( + user_id=user_id + ).values_list('group_id', flat=True) + group_auth = ResourceChatUserGroupAuthorize.objects.filter( + resource_id=OuterRef('id'), + resource_type=ResourceType.APPLICATION.value, + is_auth=True, + user_group_id__in=user_groups + ) + return queryset.filter(Exists(direct_auth) | Exists(group_auth)) + + def page(self, current_page, page_size, user_id, with_valid=True): + if with_valid: + self.is_valid(raise_exception=True) + queryset = self.get_query_set() + queryset = self._apply_auth_filter(queryset, user_id) + return page_search( + current_page, + page_size, + queryset, + post_records_handler=lambda app: ApplicationResponseSerializer(app).data, + ) + + +class PortalLoginSerializer(serializers.Serializer): + + @staticmethod + def login(instance): + username = instance.get('username', '') + encrypted_data = instance.get('encryptedData', '') + + if encrypted_data: + try: + decrypted_raw = decrypt(encrypted_data) + decrypted_data = json.loads(decrypted_raw) if decrypted_raw else {} + if isinstance(decrypted_data, dict): + instance.update(decrypted_data) + except Exception as e: + raise AppApiException(500, _("Invalid encrypted data")) + + try: + request_serializer = LoginRequest(data=instance) + request_serializer.is_valid(raise_exception=True) + except serializers.ValidationError: + raise + except Exception as e: + raise AppApiException(500, str(e)) + + validated_data = request_serializer.validated_data + username = validated_data.get('username', '') + password = validated_data.get('password', '') + captcha = validated_data.get('captcha', '') + + portal = Portal.objects.first() + if portal is None or not portal.enable_auth: + raise AppApiException(500, _("Portal authentication is not enabled")) + auth_config = portal.auth_config or {} + login_value = auth_config.get('login_value', []) + if 'LOCAL' not in login_value: + raise AppApiException(500, _("Portal local login is not enabled")) + + max_attempts = auth_config.get('max_attempts', 1) + failed_attempts = auth_config.get('failed_attempts', 5) + lock_time = auth_config.get('lock_time', 10) + + license_validator = DatabaseModelManage.get_model("license_is_valid") + is_license_valid = bool(license_validator()) if license_validator else False + + cache_key = system_get_key(f'portal_{username}') + if is_license_valid: + if PortalLoginSerializer._is_account_locked(username, failed_attempts): + raise AppApiException( + 1005, _("This account has been locked for %s minutes, please try again later") % lock_time + ) + if PortalLoginSerializer._need_captcha(username, max_attempts): + PortalLoginSerializer._validate_captcha(username, captcha) + + user = ChatUser.objects.filter(username=username).first() + + if not user or not password_verify(password, user.password): + PortalLoginSerializer._handle_failed_login(username, is_license_valid, failed_attempts, lock_time) + 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(cache_key, version=system_version) + cache.delete(system_get_key(f'portal_{username}_lock'), version=system_version) + + token = signing.dumps({ + 'username': user.username, + 'id': str(user.id), + 'type': 'PORTAL_USER', + }) + version, get_key = Cache_Version.TOKEN.value + timeout = CONFIG.get_session_timeout() + cache.set(get_key(token), user, timeout=timeout, version=version) + f_token = FileToken(str(user.id), 'PORTAL_USER').to_token() + record_log( + menu='Portal', + operate='Log in', + request=None, + user={'username': user.username}, + status=200, + operation_object={'name': user.username}, + workspace_id='default' + ) + return {'token': token}, f_token + + @staticmethod + def get_login_profile(): + portal = Portal.objects.first() + if portal is None: + raise AppApiException(500, _("Portal configuration does not exist")) + auth_config = portal.auth_config or {} + return { + 'name': portal.name, + 'description': portal.description or '', + 'logo': portal.logo or '', + 'enable_auth': portal.enable_auth, + 'authentication_type': auth_config.get('type', 'password') if portal.enable_auth else '', + 'login_value': auth_config.get('login_value', []) if portal.enable_auth else [], + 'max_attempts': auth_config.get('max_attempts', 1) if portal.enable_auth else 1, + 'rsa_key': get_key_pair_by_sql().get('key', ''), + } + + @staticmethod + def _is_account_locked(username: str, failed_attempts: int) -> bool: + if failed_attempts == -1: + return False + lock_cache = cache.get(system_get_key(f'portal_{username}_lock'), version=system_version) + return bool(lock_cache) + + @staticmethod + def _need_captcha(username: str, max_attempts: int) -> bool: + cache_key = system_get_key(f'portal_{username}') + if max_attempts == -1: + return False + if max_attempts > 0: + fail_count = cache.get(cache_key, version=system_version) or 0 + return fail_count >= max_attempts + return True + + @staticmethod + def _validate_captcha(username: str, captcha: str) -> None: + if not captcha: + raise AppApiException(1005, _("Captcha is required")) + captcha_cache = cache.get( + Cache_Version.CAPTCHA.get_key(captcha=f'portal_{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")) + + @staticmethod + def _handle_failed_login(username: str, is_license_valid: bool, failed_attempts: int, lock_time: int) -> None: + try: + _record_login_fail(username) + except Exception: + pass + lock_fail_count = 0 + try: + lock_fail_count = _record_login_fail_lock(username, lock_time) + except Exception: + pass + if not is_license_valid or failed_attempts <= 0: + return + if lock_fail_count < failed_attempts: + remain_attempts = failed_attempts - lock_fail_count + raise AppApiException( + 1005, + _("Login failed %s times, account will be locked, you have %s more chances !") + % (failed_attempts, remain_attempts), + ) + try: + cache.add( + system_get_key(f'portal_{username}_lock'), 1, + timeout=lock_time * 60, version=system_version + ) + except Exception: + pass + raise AppApiException( + 1005, _("This account has been locked for %s minutes, please try again later") % lock_time + ) + + +def _record_login_fail(username: str, expire: int = 600): + if not username: + return + fail_key = system_get_key(f'portal_{username}') + try: + cache.incr(fail_key, 1, version=system_version) + except ValueError: + cache.set(fail_key, 1, timeout=expire, version=system_version) + + +def _record_login_fail_lock(username: str, expire: int = 10): + if not username: + return 0 + lock_key = system_get_key(f'portal_{username}_lock_count') + try: + fail_count = cache.incr(lock_key, 1, version=system_version) + except ValueError: + cache.set(lock_key, 1, timeout=expire * 60, version=system_version) + fail_count = 1 + return fail_count diff --git a/apps/portal/urls.py b/apps/portal/urls.py index de6451ed57e..1825b7ab736 100644 --- a/apps/portal/urls.py +++ b/apps/portal/urls.py @@ -6,4 +6,8 @@ urlpatterns = [ path('portal', views.PortalView.as_view()), + path('portal/info', views.PortalInfoView.as_view()), + path('portal/login', views.PortalLoginView.as_view()), + path('portal/logout', views.PortalLogoutView.as_view()), + path('portal/application//', views.PortalApplicationView.as_view()), ] diff --git a/apps/portal/views/portal.py b/apps/portal/views/portal.py index 6c2c3ea4b51..a83e11420bb 100644 --- a/apps/portal/views/portal.py +++ b/apps/portal/views/portal.py @@ -15,10 +15,13 @@ from common import result from common.auth import TokenAuth from common.auth.authentication import has_permissions +from common.constants.cache_version import Cache_Version from common.constants.permission_constants import PermissionConstants, RoleConstants from common.log.log import log +from common.utils.common import query_params_to_single_dict +from django.core.cache import cache from portal.api.portal import PortalAPI -from portal.serializers.portal import PortalSerializer +from portal.serializers.portal import PortalSerializer, PortalApplicationSerializer, PortalLoginSerializer class PortalView(APIView): @@ -52,3 +55,80 @@ def get(self, request: Request): PermissionConstants.PORTAL_EDIT, RoleConstants.ADMIN) def put(self, request: Request): return result.success(PortalSerializer().edit(request.data)) + + +class PortalApplicationView(APIView): + authentication_classes = [TokenAuth] + + @extend_schema( + methods=['GET'], + description=_('Get published application list by page'), + summary=_('Get published application list by page'), + operation_id=_('Get published application list by page'), + parameters=PortalAPI.Application.get_parameters(), + responses=PortalAPI.Application.get_response(), + tags=[_('Portal')] + ) + @has_permissions(PermissionConstants.PORTAL_READ, RoleConstants.ADMIN) + def get(self, request: Request, current_page: int, page_size: int): + return result.success(PortalApplicationSerializer.Query( + data={**query_params_to_single_dict(request.query_params)} + ).page(current_page, page_size, str(request.user.id))) + + +class PortalLoginView(APIView): + + @extend_schema( + methods=['POST'], + description=_('Portal login'), + summary=_('Portal login'), + operation_id=_('Portal login'), + tags=[_('Portal')], + request=PortalAPI.Login.get_request(), + responses=PortalAPI.Login.get_response(), + ) + def post(self, request: Request): + token_data, f_token = PortalLoginSerializer.login(request.data) + response = result.success(token_data) + secure = request.is_secure() + response.set_cookie( + 'mk_file_auth', + value=f_token, + max_age=7 * 24 * 3600, + path='/portal/', + domain=None, + secure=secure, + httponly=True, + samesite='Lax', + ) + return response + + +class PortalInfoView(APIView): + + @extend_schema( + methods=['GET'], + description=_('Get portal login info'), + summary=_('Get portal login info'), + operation_id=_('Get portal login info'), + tags=[_('Portal')], + ) + def get(self, request: Request): + return result.success(PortalLoginSerializer.get_login_profile()) + + +class PortalLogoutView(APIView): + + @extend_schema( + methods=['POST'], + summary=_('Portal logout'), + description=_('Portal logout'), + operation_id=_('Portal logout'), + tags=[_('Portal')], + responses=PortalAPI.Logout.get_response(), + ) + @log(menu='Portal', operate='Log out') + def post(self, request: Request): + version, get_key = Cache_Version.TOKEN.value + cache.delete(get_key(token=request.META.get('HTTP_AUTHORIZATION')[7:]), version=version) + return result.success(True)