Skip to content

Commit

Permalink
feat: login asset face verify acl
Browse files Browse the repository at this point in the history
  • Loading branch information
Aaron3S committed Dec 9, 2024
1 parent a6d040c commit 0135021
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 75 deletions.
2 changes: 2 additions & 0 deletions apps/acls/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
25 changes: 24 additions & 1 deletion apps/authentication/api/connection_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
)
Expand All @@ -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 \
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
37 changes: 20 additions & 17 deletions apps/authentication/api/mfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand All @@ -74,42 +78,41 @@ 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({
'is_finished': True,
'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)
Expand Down
6 changes: 3 additions & 3 deletions apps/authentication/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions apps/authentication/mfa/face.py
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
113 changes: 65 additions & 48 deletions apps/authentication/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#
import inspect
import time
import uuid
from functools import partial
from typing import Callable

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 3 additions & 4 deletions apps/users/views/profile/face.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 0135021

Please sign in to comment.