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')
|
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')
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue