Merge pull request #14610 from jumpserver/pr@dev@feat_face_login_acl

feat: login asset face verify acl
pull/14618/head
Chenyang Shen 2024-12-09 11:34:09 +08:00 committed by GitHub
commit 4728f95634
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 119 additions and 75 deletions

View File

@ -9,3 +9,5 @@ class ActionChoices(models.TextChoices):
warning = 'warning', _('Warn') warning = 'warning', _('Warn')
notice = 'notice', _('Notify') notice = 'notice', _('Notify')
notify_and_warn = 'notify_and_warn', _('Notify and warn') notify_and_warn = 'notify_and_warn', _('Notify and warn')
face_verify = 'face_verify', _('Face Verify')
face_online = 'face_online', _('Face Online')

View File

@ -29,6 +29,7 @@ from terminal.models import EndpointRule, Endpoint
from users.const import FileNameConflictResolution from users.const import FileNameConflictResolution
from users.const import RDPSmartSize, RDPColorQuality from users.const import RDPSmartSize, RDPColorQuality
from users.models import Preference from users.models import Preference
from ..mixins import AuthFaceMixin
from ..models import ConnectionToken, date_expired_default from ..models import ConnectionToken, date_expired_default
from ..serializers import ( from ..serializers import (
ConnectionTokenSerializer, ConnectionTokenSecretSerializer, ConnectionTokenSerializer, ConnectionTokenSecretSerializer,
@ -283,7 +284,7 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet): class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixin, JMSModelViewSet):
filterset_fields = ( filterset_fields = (
'user_display', 'asset_display' 'user_display', 'asset_display'
) )
@ -304,6 +305,7 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
'get_client_protocol_url': 'authentication.add_connectiontoken', 'get_client_protocol_url': 'authentication.add_connectiontoken',
} }
input_username = '' input_username = ''
need_face_verify = False
def get_queryset(self): def get_queryset(self):
queryset = ConnectionToken.objects \ queryset = ConnectionToken.objects \
@ -388,6 +390,8 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
ticket = self._validate_acl(user, asset, account) ticket = self._validate_acl(user, asset, account)
if ticket: if ticket:
data['from_ticket'] = ticket data['from_ticket'] = ticket
if ticket or self.need_face_verify:
data['is_active'] = False data['is_active'] = False
return data return data
@ -444,6 +448,12 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
assignees=acl.reviewers.all(), org_id=asset.org_id assignees=acl.reviewers.all(), org_id=asset.org_id
) )
return ticket 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): if acl.is_action(acl.ActionChoices.notice):
reviewers = acl.reviewers.all() reviewers = acl.reviewers.all()
if not reviewers: if not reviewers:
@ -455,9 +465,22 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
reviewer, asset, user, account, self.input_username reviewer, asset, user, account, self.input_username
).publish_async() ).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): def create(self, request, *args, **kwargs):
try: try:
response = super().create(request, *args, **kwargs) response = super().create(request, *args, **kwargs)
if self.need_face_verify:
self.create_face_verify(response)
except JMSException as e: except JMSException as e:
data = {'code': e.detail.code, 'detail': e.detail} data = {'code': e.detail.code, 'detail': e.detail}
return Response(data, status=e.status_code) return Response(data, status=e.status_code)

View File

@ -15,12 +15,14 @@ from rest_framework.exceptions import NotFound
from common.exceptions import JMSException, UnexpectError from common.exceptions import JMSException, UnexpectError
from common.permissions import WithBootstrapToken, IsServiceAccount from common.permissions import WithBootstrapToken, IsServiceAccount
from common.utils import get_logger from common.utils import get_logger
from orgs.utils import tmp_to_root_org
from users.models.user import User from users.models.user import User
from .. import errors from .. import errors
from .. import serializers 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 ..errors import SessionEmptyError
from ..mixins import AuthMixin from ..mixins import AuthMixin
from ..models import ConnectionToken
logger = get_logger(__name__) logger = get_logger(__name__)
@ -49,13 +51,15 @@ class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
if not face_code: if not face_code:
self._update_context_with_error(context, "missing field 'face_code'") self._update_context_with_error(context, "missing field 'face_code'")
raise ValidationError({'error': "missing field 'face_code'"}) raise ValidationError({'error': "missing field 'face_code'"})
try:
self._handle_success(context, face_code) self._handle_success(context, face_code)
except Exception as e:
self._update_context_with_error(context, str(e))
return Response(status=200) return Response(status=200)
@staticmethod @staticmethod
def get_face_cache_key(token): 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): def _get_context_from_cache(self, token):
cache_key = self.get_face_cache_key(token) cache_key = self.get_face_cache_key(token)
@ -74,7 +78,7 @@ class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
def _update_cache(self, context): def _update_cache(self, context):
cache_key = self.get_face_cache_key(context['token']) 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): def _handle_success(self, context, face_code):
context.update({ context.update({
@ -82,34 +86,33 @@ class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
'success': True, 'success': True,
'face_code': face_code '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) self._update_cache(context)
class MFAFaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView): class MFAFaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
face_token_session_key = MFA_FACE_SESSION_KEY face_token_session_key = FACE_SESSION_KEY
@staticmethod @staticmethod
def get_face_cache_key(token): 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): def new_face_context(self):
token = uuid.uuid4().hex return self.create_face_verify_context()
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
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
token = self.new_face_context() token = self.new_face_context()
return Response({'token': token}) return Response({'token': token})
def get(self, request, *args, **kwargs): 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) cache_key = self.get_face_cache_key(token)
context = cache.get(cache_key) context = cache.get(cache_key)

View File

@ -40,6 +40,6 @@ class MFAType(TextChoices):
Custom = MFACustom.name, MFACustom.display_name Custom = MFACustom.name, MFACustom.display_name
MFA_FACE_CONTEXT_CACHE_KEY_PREFIX = "MFA_FACE_RECOGNITION_CONTEXT" FACE_CONTEXT_CACHE_KEY_PREFIX = "FACE_CONTEXT"
MFA_FACE_CONTEXT_CACHE_TTL = 60 FACE_CONTEXT_CACHE_TTL = 60
MFA_FACE_SESSION_KEY = "mfa_face_token" FACE_SESSION_KEY = "face_token"

View File

@ -1,12 +1,12 @@
from authentication.mfa.base import BaseMFA from authentication.mfa.base import BaseMFA
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from authentication.mixins import MFAFaceMixin from authentication.mixins import AuthFaceMixin
from common.const import LicenseEditionChoices from common.const import LicenseEditionChoices
from settings.api import settings from settings.api import settings
class MFAFace(BaseMFA, MFAFaceMixin): class MFAFace(BaseMFA, AuthFaceMixin):
name = "face" name = "face"
display_name = _('Face Recognition') display_name = _('Face Recognition')
placeholder = 'Face Recognition' placeholder = 'Face Recognition'

View File

@ -2,6 +2,7 @@
# #
import inspect import inspect
import time import time
import uuid
from functools import partial from functools import partial
from typing import Callable from typing import Callable
@ -199,53 +200,6 @@ class AuthPreCheckMixin:
self.raise_credential_error(errors.reason_user_not_exist) 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: class MFAMixin:
request: Request request: Request
get_user_from_session: Callable get_user_from_session: Callable
@ -475,7 +429,70 @@ class AuthACLMixin:
return ticket 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 request = None
partial_credential_error = None partial_credential_error = None

View File

@ -3,12 +3,12 @@ from django import forms
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from authentication import errors from authentication import errors
from authentication.mixins import AuthMixin, MFAFaceMixin from authentication.mixins import AuthMixin
__all__ = ['UserFaceCaptureView', 'UserFaceEnableView', __all__ = ['UserFaceCaptureView', 'UserFaceEnableView',
'UserFaceDisableView'] 'UserFaceDisableView']
from common.utils import reverse, FlashMessageUtil from common.utils import FlashMessageUtil
class UserFaceCaptureForm(forms.Form): class UserFaceCaptureForm(forms.Form):
@ -39,9 +39,8 @@ class UserFaceCaptureView(AuthMixin, FormView):
return context return context
class UserFaceEnableView(MFAFaceMixin, UserFaceCaptureView): class UserFaceEnableView(UserFaceCaptureView):
def form_valid(self, form): def form_valid(self, form):
try: try:
code = self.get_face_code() code = self.get_face_code()
user = self.get_user_from_session() user = self.get_user_from_session()