mirror of https://github.com/jumpserver/jumpserver
Merge pull request #14610 from jumpserver/pr@dev@feat_face_login_acl
feat: login asset face verify aclpull/14618/head
commit
4728f95634
|
@ -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')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue