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')
notice = 'notice', _('Notify')
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 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 @@ class ExtraActionApiMixin(RDPFileClientProtocolURLMixin):
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 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
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 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
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 @@ class ConnectionTokenViewSet(ExtraActionApiMixin, RootOrgViewMixin, JMSModelView
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)

View File

@ -15,12 +15,14 @@ from rest_framework.exceptions import NotFound
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 @@ class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
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 @@ class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
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 @@ class MFAFaceCallbackApi(AuthMixin, CreateAPIView):
'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)

View File

@ -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"

View File

@ -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'

View File

@ -2,6 +2,7 @@
#
import inspect
import time
import uuid
from functools import partial
from typing import Callable
@ -199,53 +200,6 @@ class AuthPreCheckMixin:
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 @@ class AuthACLMixin:
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

View File

@ -3,12 +3,12 @@ from django import forms
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 @@ class UserFaceCaptureView(AuthMixin, FormView):
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()