feat: face online

pull/14654/head
Aaron3S 2024-12-12 17:07:26 +08:00 committed by Bryan
parent e2bf56e624
commit d1ea31c9a4
12 changed files with 237 additions and 19 deletions

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 .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:

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -450,6 +450,7 @@ class AuthFaceMixin:
context_data = {
"action": "mfa",
"token": token,
"user_id": self.request.user.id,
"is_finished": False
}
if data:

View File

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

View File

@ -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},
}

View File

@ -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')},

View File

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

View File

@ -34,7 +34,7 @@
<script>
$(document).ready(function () {
const apiUrl = "{% url 'api-auth:mfa-face-context' %}";
const apiUrl = "{% url 'api-auth:face-context' %}";
const faceCaptureUrl = "/facelive/capture";
let token;

View File

@ -26,8 +26,11 @@ urlpatterns = [
path('lark/event/subscription/callback/', api.LarkEventSubscriptionCallback.as_view(),
name='lark-event-subscription-callback'),
path('face/callback/', api.FaceCallbackApi.as_view(), name='mfa-face-callback'),
path('face/context/', api.FaceContextApi.as_view(), name='mfa-face-context'),
path('face/callback/', api.FaceCallbackApi.as_view(), name='face-callback'),
path('face/context/', api.FaceContextApi.as_view(), name='face-context'),
path('face-monitor/callback/', api.FaceMonitorCallbackApi.as_view(), name='face-monitor-callback'),
path('face-monitor/context/', api.FaceMonitorContextApi.as_view(), name='face-monitor-context'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('confirm-oauth/', api.ConfirmBindORUNBindOAuth.as_view(), name='confirm-oauth'),

View File

@ -24,12 +24,14 @@ class FaceMixin:
raise ValidationError("Face vector is not set.")
return self._decode_base64_vector(str(self.face_vector))
def check_face(self, code) -> bool:
def check_face(self, code, distance_threshold=None, similarity_threshold=None) -> bool:
distance = self.compare_euclidean_distance(code)
similarity = self.compare_cosine_similarity(code)
return distance < settings.FACE_RECOGNITION_DISTANCE_THRESHOLD \
and similarity > settings.FACE_RECOGNITION_COSINE_THRESHOLD
distance_threshold = distance_threshold or settings.FACE_RECOGNITION_DISTANCE_THRESHOLD
similarity_threshold = similarity_threshold or settings.FACE_RECOGNITION_COSINE_THRESHOLD
return distance < distance_threshold and similarity > similarity_threshold
def compare_euclidean_distance(self, base64_vector: str) -> float:
target_vector = self._decode_base64_vector(base64_vector)