From 013502186be6b250ac6274f771ace71660e67588 Mon Sep 17 00:00:00 2001 From: Aaron3S Date: Fri, 6 Dec 2024 18:19:36 +0800 Subject: [PATCH] feat: login asset face verify acl --- apps/acls/const.py | 2 + apps/authentication/api/connection_token.py | 25 ++++- apps/authentication/api/mfa.py | 37 ++++--- apps/authentication/const.py | 6 +- apps/authentication/mfa/face.py | 4 +- apps/authentication/mixins.py | 113 +++++++++++--------- apps/users/views/profile/face.py | 7 +- 7 files changed, 119 insertions(+), 75 deletions(-) diff --git a/apps/acls/const.py b/apps/acls/const.py index f36bfc21e780..486b0cae1113 100644 --- a/apps/acls/const.py +++ b/apps/acls/const.py @@ -9,3 +9,5 @@ class ActionChoices(models.TextChoices): warning = 'warning', _('Warn') notice = 'notice', _('Notify') notify_and_warn = 'notify_and_warn', _('Notify and warn') + face_verify = 'face_verify', _('Face Verify') + face_online = 'face_online', _('Face Online') diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 05135bc97ece..dba45878785a 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -29,6 +29,7 @@ from users.const import FileNameConflictResolution from users.const import RDPSmartSize, RDPColorQuality from users.models import Preference +from ..mixins import AuthFaceMixin from ..models import ConnectionToken, date_expired_default from ..serializers import ( ConnectionTokenSerializer, ConnectionTokenSecretSerializer, @@ -283,7 +284,7 @@ def exchange(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) -class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet): +class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet): filterset_fields = ( 'user_display', 'asset_display' ) @@ -304,6 +305,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView 'get_client_protocol_url': 'authentication.add_connectiontoken', } input_username = '' + need_face_verify = False def get_queryset(self): queryset = ConnectionToken.objects \ @@ -388,6 +390,8 @@ def _validate(self, user, asset, account_name, protocol): ticket = self._validate_acl(user, asset, account) if ticket: data['from_ticket'] = ticket + + if ticket or self.need_face_verify: data['is_active'] = False return data @@ -444,6 +448,12 @@ def _validate_acl(self, user, asset, account): assignees=acl.reviewers.all(), org_id=asset.org_id ) return ticket + if acl.is_action(acl.ActionChoices.face_verify) \ + or acl.is_action(acl.ActionChoices.face_online): + if not self.request.query_params.get('face_verify'): + msg = _('ACL action is face verify') + raise JMSException(code='acl_face_verify', detail=msg) + self.need_face_verify = True if acl.is_action(acl.ActionChoices.notice): reviewers = acl.reviewers.all() if not reviewers: @@ -455,9 +465,22 @@ def _validate_acl(self, user, asset, account): reviewer, asset, user, account, self.input_username ).publish_async() + def create_face_verify(self, response): + if not self.request.user.face_vector: + raise JMSException(code='no_face_feature', detail=_('No available face feature')) + connection_token_id = response.data.get('id') + context_data = { + "action": "login_asset", + "connection_token_id": connection_token_id, + } + face_verify_token = self.create_face_verify_context(context_data) + response.data['face_token'] = face_verify_token + def create(self, request, *args, **kwargs): try: response = super().create(request, *args, **kwargs) + if self.need_face_verify: + self.create_face_verify(response) except JMSException as e: data = {'code': e.detail.code, 'detail': e.detail} return Response(data, status=e.status_code) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index c02970608c14..0561753ad330 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -15,12 +15,14 @@ from common.exceptions import JMSException, UnexpectError from common.permissions import WithBootstrapToken, IsServiceAccount from common.utils import get_logger +from orgs.utils import tmp_to_root_org from users.models.user import User from .. import errors from .. import serializers -from ..const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX, MFA_FACE_SESSION_KEY, MFA_FACE_CONTEXT_CACHE_TTL +from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL from ..errors import SessionEmptyError from ..mixins import AuthMixin +from ..models import ConnectionToken logger = get_logger(__name__) @@ -49,13 +51,15 @@ def perform_create(self, serializer): if not face_code: self._update_context_with_error(context, "missing field 'face_code'") raise ValidationError({'error': "missing field 'face_code'"}) - - self._handle_success(context, face_code) + try: + self._handle_success(context, face_code) + except Exception as e: + self._update_context_with_error(context, str(e)) return Response(status=200) @staticmethod def get_face_cache_key(token): - return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" + return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" def _get_context_from_cache(self, token): cache_key = self.get_face_cache_key(token) @@ -74,7 +78,7 @@ def _update_context_with_error(self, context, error_message): def _update_cache(self, context): cache_key = self.get_face_cache_key(context['token']) - cache.set(cache_key, context, MFA_FACE_CONTEXT_CACHE_TTL) + cache.set(cache_key, context, FACE_CONTEXT_CACHE_TTL) def _handle_success(self, context, face_code): context.update({ @@ -82,34 +86,33 @@ def _handle_success(self, context, face_code): 'success': True, 'face_code': face_code }) + action = context.get('action', None) + if action == 'login_asset': + with tmp_to_root_org(): + connection_token_id = context.get('connection_token_id') + token = ConnectionToken.objects.filter(id=connection_token_id).first() + token.is_active = True + token.save() self._update_cache(context) class MFAFaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView): permission_classes = (AllowAny,) - face_token_session_key = MFA_FACE_SESSION_KEY + face_token_session_key = FACE_SESSION_KEY @staticmethod def get_face_cache_key(token): - return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" + return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" def new_face_context(self): - token = uuid.uuid4().hex - cache_key = self.get_face_cache_key(token) - face_context = { - "token": token, - "is_finished": False - } - cache.set(cache_key, face_context, MFA_FACE_CONTEXT_CACHE_TTL) - self.request.session[self.face_token_session_key] = token - return token + return self.create_face_verify_context() def post(self, request, *args, **kwargs): token = self.new_face_context() return Response({'token': token}) def get(self, request, *args, **kwargs): - token = self.request.session.get('mfa_face_token') + token = self.request.session.get(self.face_token_session_key) cache_key = self.get_face_cache_key(token) context = cache.get(cache_key) diff --git a/apps/authentication/const.py b/apps/authentication/const.py index 927f6d2042c8..37e1eab661ca 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -40,6 +40,6 @@ class MFAType(TextChoices): Custom = MFACustom.name, MFACustom.display_name -MFA_FACE_CONTEXT_CACHE_KEY_PREFIX = "MFA_FACE_RECOGNITION_CONTEXT" -MFA_FACE_CONTEXT_CACHE_TTL = 60 -MFA_FACE_SESSION_KEY = "mfa_face_token" +FACE_CONTEXT_CACHE_KEY_PREFIX = "FACE_CONTEXT" +FACE_CONTEXT_CACHE_TTL = 60 +FACE_SESSION_KEY = "face_token" diff --git a/apps/authentication/mfa/face.py b/apps/authentication/mfa/face.py index 02f1e463cab2..4d847cb99bd8 100644 --- a/apps/authentication/mfa/face.py +++ b/apps/authentication/mfa/face.py @@ -1,12 +1,12 @@ from authentication.mfa.base import BaseMFA from django.utils.translation import gettext_lazy as _ -from authentication.mixins import MFAFaceMixin +from authentication.mixins import AuthFaceMixin from common.const import LicenseEditionChoices from settings.api import settings -class MFAFace(BaseMFA, MFAFaceMixin): +class MFAFace(BaseMFA, AuthFaceMixin): name = "face" display_name = _('Face Recognition') placeholder = 'Face Recognition' diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 92b49f64e227..721254a379ca 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -2,6 +2,7 @@ # import inspect import time +import uuid from functools import partial from typing import Callable @@ -199,53 +200,6 @@ def _check_only_allow_exists_user_auth(self, username): self.raise_credential_error(errors.reason_user_not_exist) -class MFAFaceMixin: - request = None - - def get_face_recognition_token(self): - from authentication.const import MFA_FACE_SESSION_KEY - token = self.request.session.get(MFA_FACE_SESSION_KEY) - if not token: - raise ValueError("Face recognition token is missing from the session.") - return token - - @staticmethod - def get_face_cache_key(token): - from authentication.const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX - return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" - - def get_face_recognition_context(self): - token = self.get_face_recognition_token() - cache_key = self.get_face_cache_key(token) - context = cache.get(cache_key) - if not context: - raise ValueError(f"Face recognition context does not exist for token: {token}") - return context - - @staticmethod - def is_context_finished(context): - return context.get('is_finished', False) - - @staticmethod - def is_context_success(context): - return context.get('success', False) - - def get_face_code(self): - context = self.get_face_recognition_context() - - if not self.is_context_finished(context): - raise RuntimeError("Face recognition is not yet completed.") - - if not self.is_context_success(context): - msg = context.get('error_message', '') - raise RuntimeError(msg) - - face_code = context.get('face_code') - if not face_code: - raise ValueError("Face code is missing from the context.") - return face_code - - class MFAMixin: request: Request get_user_from_session: Callable @@ -475,7 +429,70 @@ def get_ticket(self): return ticket -class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin): +class AuthFaceMixin: + request: Request + + @staticmethod + def _get_face_cache_key(token): + from authentication.const import FACE_CONTEXT_CACHE_KEY_PREFIX + return f"{FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" + + @staticmethod + def _is_context_finished(context): + return context.get('is_finished', False) + + @staticmethod + def _is_context_success(context): + return context.get('success', False) + + def create_face_verify_context(self, data=None): + token = uuid.uuid4().hex + context_data = { + "action": "mfa", + "token": token, + "is_finished": False + } + if data: + context_data.update(data) + + cache_key = self._get_face_cache_key(token) + from .const import FACE_CONTEXT_CACHE_TTL, FACE_SESSION_KEY + cache.set(cache_key, context_data, FACE_CONTEXT_CACHE_TTL) + self.request.session[FACE_SESSION_KEY] = token + return token + + def get_face_token_from_session(self): + from authentication.const import FACE_SESSION_KEY + token = self.request.session.get(FACE_SESSION_KEY) + if not token: + raise ValueError("Face recognition token is missing from the session.") + return token + + def get_face_verify_context(self): + token = self.get_face_token_from_session() + cache_key = self._get_face_cache_key(token) + context = cache.get(cache_key) + if not context: + raise ValueError(f"Face recognition context does not exist for token: {token}") + return context + + def get_face_code(self): + context = self.get_face_verify_context() + + if not self._is_context_finished(context): + raise RuntimeError("Face recognition is not yet completed.") + + if not self._is_context_success(context): + msg = context.get('error_message', '') + raise RuntimeError(msg) + + face_code = context.get('face_code') + if not face_code: + raise ValueError("Face code is missing from the context.") + return face_code + + +class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, AuthFaceMixin, MFAMixin, AuthPostCheckMixin, ): request = None partial_credential_error = None diff --git a/apps/users/views/profile/face.py b/apps/users/views/profile/face.py index d296f7de8aea..890b5d362f64 100644 --- a/apps/users/views/profile/face.py +++ b/apps/users/views/profile/face.py @@ -3,12 +3,12 @@ from django.utils.translation import gettext_lazy as _ from authentication import errors -from authentication.mixins import AuthMixin, MFAFaceMixin +from authentication.mixins import AuthMixin __all__ = ['UserFaceCaptureView', 'UserFaceEnableView', 'UserFaceDisableView'] -from common.utils import reverse, FlashMessageUtil +from common.utils import FlashMessageUtil class UserFaceCaptureForm(forms.Form): @@ -39,9 +39,8 @@ def get_context_data(self, **kwargs): return context -class UserFaceEnableView(MFAFaceMixin, UserFaceCaptureView): +class UserFaceEnableView(UserFaceCaptureView): def form_valid(self, form): - try: code = self.get_face_code() user = self.get_user_from_session()