diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 526eee480..469a12179 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -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 .face import FaceMonitorContext from ..mixins import AuthFaceMixin from ..models import ConnectionToken, date_expired_default from ..serializers import ( @@ -338,6 +339,7 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi } input_username = '' need_face_verify = False + face_monitor_token = '' def get_queryset(self): queryset = ConnectionToken.objects \ @@ -425,6 +427,10 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi if ticket or self.need_face_verify: data['is_active'] = False + if self.face_monitor_token: + FaceMonitorContext.get_or_create_context(self.face_monitor_token, + self.request.user.id) + data['face_monitor_token'] = self.face_monitor_token return data @staticmethod @@ -480,12 +486,22 @@ class ConnectionTokenViewSet(AuthFaceMixin, ExtraActionApiMixin, RootOrgViewMixi 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 acl.is_action(acl.ActionChoices.face_verify): 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.face_online): + face_verify = self.request.query_params.get('face_verify') + face_monitor_token = self.request.query_params.get('face_monitor_token') + + if not face_verify or not face_monitor_token: + msg = _('ACL action is face online') + raise JMSException(code='acl_face_online', detail=msg) + + self.need_face_verify = True + self.face_monitor_token = face_monitor_token + if acl.is_action(acl.ActionChoices.notice): reviewers = acl.reviewers.all() if not reviewers: diff --git a/apps/authentication/api/face.py b/apps/authentication/api/face.py index 108816afb..109b04b92 100644 --- a/apps/authentication/api/face.py +++ b/apps/authentication/api/face.py @@ -1,4 +1,5 @@ from django.core.cache import cache +from django.utils.translation import gettext as _ from rest_framework.generics import CreateAPIView, RetrieveAPIView from rest_framework.response import Response from rest_framework.serializers import ValidationError @@ -6,18 +7,25 @@ from rest_framework.permissions import AllowAny from rest_framework.exceptions import NotFound from common.permissions import IsServiceAccount -from common.utils import get_logger +from common.utils import get_logger, get_object_or_none from orgs.utils import tmp_to_root_org +from terminal.api.session.task import create_sessions_tasks +from users.models import User from .. import serializers from ..mixins import AuthMixin -from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL +from ..const import FACE_CONTEXT_CACHE_KEY_PREFIX, FACE_SESSION_KEY, FACE_CONTEXT_CACHE_TTL, FaceMonitorActionChoices from ..models import ConnectionToken +from ..serializers.face import FaceMonitorCallbackSerializer, FaceMonitorContextSerializer logger = get_logger(__name__) __all__ = [ - 'FaceCallbackApi', 'FaceContextApi' + 'FaceCallbackApi', + 'FaceContextApi', + 'FaceMonitorContext', + 'FaceMonitorContextApi', + 'FaceMonitorCallbackApi' ] @@ -77,11 +85,20 @@ class FaceCallbackApi(AuthMixin, CreateAPIView): }) 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() + user_id = context.get('user_id') + user = User.objects.get(id=user_id) + + if user.check_face(face_code): + 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() + else: + context.update({ + 'success': False, + 'error_message': _('Facial comparison failed') + }) self._update_cache(context) @@ -113,3 +130,123 @@ class FaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView): "success": context.get('success', False), "error_message": context.get("error_message", '') }) + + +class FaceMonitorContext: + def __init__(self, token, user_id, session_ids=None): + self.token = token + self.user_id = user_id + if session_ids is None: + self.session_ids = [] + else: + self.session_ids = session_ids + + @classmethod + def get_cache_key(cls, token): + return 'FACE_MONITOR_CONTEXT_{}'.format(token) + + @classmethod + def get_or_create_context(cls, token, user_id): + context = cls.get(token) + if not context: + context = FaceMonitorContext(token=token, + user_id=user_id) + context.save() + return context + + def add_session(self, session_id): + self.session_ids.append(session_id) + self.save() + + @classmethod + def get(cls, token): + cache_key = cls.get_cache_key(token) + return cache.get(cache_key, None) + + def save(self): + cache_key = self.get_cache_key(self.token) + cache.set(cache_key, self) + + def close(self): + self.terminal_sessions() + self._destroy() + + def _destroy(self): + cache_key = self.get_cache_key(self.token) + cache.delete(cache_key) + + def pause_sessions(self): + self._send_task('lock_session') + + def resume_sessions(self): + self._send_task('unlock_session') + + def terminal_sessions(self): + self._send_task("kill_session") + + def _send_task(self, task_name): + create_sessions_tasks(self.session_ids, 'Administrator', task_name=task_name) + + +class FaceMonitorContextApi(CreateAPIView): + permission_classes = (IsServiceAccount,) + serializer_class = FaceMonitorContextSerializer + + def perform_create(self, serializer): + face_monitor_token = serializer.validated_data.get('face_monitor_token') + session_id = serializer.validated_data.get('session_id') + + context = FaceMonitorContext.get(face_monitor_token) + context.add_session(session_id) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return Response(status=201) + + +class FaceMonitorCallbackApi(CreateAPIView): + permission_classes = (IsServiceAccount,) + serializer_class = FaceMonitorCallbackSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + token = serializer.validated_data.get('token') + + context = FaceMonitorContext.get(token=token) + is_finished = serializer.validated_data.get('is_finished') + if is_finished: + context.close() + return Response(status=200) + + action = serializer.validated_data.get('action') + if action == FaceMonitorActionChoices.Verify: + user = get_object_or_none(User, pk=context.user_id) + face_codes = serializer.validated_data.get('face_codes') + + if not user: + return Response(data={'msg': 'user {} not found' + .format(context.user_id)}, status=400) + + if not self._check_face_codes(face_codes, user): + return Response(data={'msg': 'face codes not matched'}, status=400) + + if action == FaceMonitorActionChoices.Pause: + context.pause_sessions() + if action == FaceMonitorActionChoices.Resume: + context.resume_sessions() + return Response(status=200) + + @staticmethod + def _check_face_codes(face_codes, user): + matched = False + for face_code in face_codes: + matched = user.check_face(face_code, + distance_threshold=0.45, + similarity_threshold=0.92) + if matched: + break + return matched diff --git a/apps/authentication/const.py b/apps/authentication/const.py index 37e1eab66..9c9355caf 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -43,3 +43,9 @@ class MFAType(TextChoices): FACE_CONTEXT_CACHE_KEY_PREFIX = "FACE_CONTEXT" FACE_CONTEXT_CACHE_TTL = 60 FACE_SESSION_KEY = "face_token" + + +class FaceMonitorActionChoices(TextChoices): + Verify = 'verify', 'verify' + Pause = 'pause', 'pause' + Resume = 'resume', 'resume' diff --git a/apps/authentication/migrations/0004_connectiontoken_face_monitor_token.py b/apps/authentication/migrations/0004_connectiontoken_face_monitor_token.py new file mode 100644 index 000000000..7562a3e61 --- /dev/null +++ b/apps/authentication/migrations/0004_connectiontoken_face_monitor_token.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.13 on 2024-12-11 02:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_sshkey'), + ] + + operations = [ + migrations.AddField( + model_name='connectiontoken', + name='face_monitor_token', + field=models.CharField(blank=True, max_length=128, null=True, verbose_name='Face monitor token'), + ), + ] diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 721254a37..0b2792507 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -450,6 +450,7 @@ class AuthFaceMixin: context_data = { "action": "mfa", "token": token, + "user_id": self.request.user.id, "is_finished": False } if data: diff --git a/apps/authentication/models/connection_token.py b/apps/authentication/models/connection_token.py index fcf1d43c8..cabb8932d 100644 --- a/apps/authentication/models/connection_token.py +++ b/apps/authentication/models/connection_token.py @@ -50,6 +50,7 @@ class ConnectionToken(JMSOrgBaseModel): on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('From ticket') ) + face_monitor_token = models.CharField(max_length=128, null=True, blank=True, verbose_name=_("Face monitor token")) is_active = models.BooleanField(default=True, verbose_name=_("Active")) class Meta: diff --git a/apps/authentication/serializers/connect_token_secret.py b/apps/authentication/serializers/connect_token_secret.py index 500daa410..40d6a0352 100644 --- a/apps/authentication/serializers/connect_token_secret.py +++ b/apps/authentication/serializers/connect_token_secret.py @@ -148,9 +148,10 @@ class ConnectionTokenSecretSerializer(OrgResourceModelSerializerMixin): 'platform', 'command_filter_acls', 'protocol', 'domain', 'gateway', 'actions', 'expire_at', 'from_ticket', 'expire_now', 'connect_method', - 'connect_options', + 'connect_options', 'face_monitor_token' ] extra_kwargs = { + 'face_monitor_token': {'read_only': True}, 'value': {'read_only': True}, } diff --git a/apps/authentication/serializers/connection_token.py b/apps/authentication/serializers/connection_token.py index 5781b020d..6167360d1 100644 --- a/apps/authentication/serializers/connection_token.py +++ b/apps/authentication/serializers/connection_token.py @@ -28,7 +28,7 @@ class ConnectionTokenSerializer(CommonModelSerializer): 'connect_method', 'connect_options', 'protocol', 'actions', 'is_active', 'is_reusable', 'from_ticket', 'from_ticket_info', 'date_expired', 'date_created', 'date_updated', 'created_by', - 'updated_by', 'org_id', 'org_name', + 'updated_by', 'org_id', 'org_name','face_monitor_token', ] read_only_fields = [ # 普通 Token 不支持指定 user @@ -37,6 +37,7 @@ class ConnectionTokenSerializer(CommonModelSerializer): ] fields = fields_small + read_only_fields extra_kwargs = { + 'face_monitor_token': {'read_only': True}, 'from_ticket': {'read_only': True}, 'value': {'read_only': True}, 'is_expired': {'read_only': True, 'label': _('Is expired')}, diff --git a/apps/authentication/serializers/face.py b/apps/authentication/serializers/face.py index 52094a5cc..961ae7557 100644 --- a/apps/authentication/serializers/face.py +++ b/apps/authentication/serializers/face.py @@ -1,9 +1,12 @@ +from django.core.validators import RegexValidator from rest_framework import serializers __all__ = [ - 'FaceCallbackSerializer' + 'FaceCallbackSerializer', 'FaceMonitorCallbackSerializer' ] +from authentication.const import FaceMonitorActionChoices + class FaceCallbackSerializer(serializers.Serializer): token = serializers.CharField(required=True, allow_blank=False) @@ -16,3 +19,32 @@ class FaceCallbackSerializer(serializers.Serializer): def create(self, validated_data): pass + + +class FaceMonitorContextSerializer(serializers.Serializer): + session_id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + face_monitor_token = serializers.CharField(required=True, allow_blank=False, allow_null=False) + + def update(self, instance, validated_data): + pass + + def create(self, validated_data): + pass + + +class FaceMonitorCallbackSerializer(serializers.Serializer): + token = serializers.CharField(required=True, allow_blank=False) + is_finished = serializers.BooleanField(required=True) + success = serializers.BooleanField(required=True) + error_message = serializers.CharField(required=True, allow_blank=True) + action = serializers.ChoiceField(required=True, choices=FaceMonitorActionChoices.choices) + face_codes = serializers.ListField( + required=False, allow_null=True, allow_empty=True, + child=serializers.CharField(), + ) + + def update(self, instance, validated_data): + pass + + def create(self, validated_data): + pass diff --git a/apps/authentication/templates/authentication/face_capture.html b/apps/authentication/templates/authentication/face_capture.html index a6342bace..6b9a967d2 100644 --- a/apps/authentication/templates/authentication/face_capture.html +++ b/apps/authentication/templates/authentication/face_capture.html @@ -34,7 +34,7 @@