From 86273865c8e4c845a3137977ce275227e4fbba53 Mon Sep 17 00:00:00 2001 From: Aaron3S Date: Tue, 12 Nov 2024 17:28:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=BA=BA=E8=84=B8?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/mfa.py | 103 +++++++++++++++++- apps/authentication/confirm/mfa.py | 1 + apps/authentication/const.py | 7 +- apps/authentication/mfa/__init__.py | 1 + apps/authentication/mfa/base.py | 4 + apps/authentication/mfa/face.py | 53 +++++++++ apps/authentication/mfa/radius.py | 2 +- apps/authentication/mixins.py | 41 ++++++- .../serializers/password_mfa.py | 14 +++ .../authentication/face_capture.html | 92 ++++++++++++++++ apps/authentication/urls/api_urls.py | 2 + apps/authentication/urls/view_urls.py | 3 + apps/authentication/views/mfa.py | 18 ++- apps/jumpserver/conf.py | 7 ++ apps/jumpserver/settings/auth.py | 8 +- apps/templates/_mfa_login_field.html | 20 ++-- .../users/migrations/0002_user_face_vector.py | 19 ++++ apps/users/models/user/__init__.py | 12 +- apps/users/models/user/_face.py | 56 ++++++++++ apps/users/views/profile/__init__.py | 1 + apps/users/views/profile/face.py | 62 +++++++++++ config_example.yml | 5 +- 22 files changed, 512 insertions(+), 19 deletions(-) create mode 100644 apps/authentication/mfa/face.py create mode 100644 apps/authentication/templates/authentication/face_capture.html create mode 100644 apps/users/migrations/0002_user_face_vector.py create mode 100644 apps/users/models/user/_face.py create mode 100644 apps/users/views/profile/face.py diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index 703ddccbd..4ba5665d4 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -1,29 +1,128 @@ # -*- coding: utf-8 -*- # +import uuid +from django.core.cache import cache from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ from rest_framework import exceptions -from rest_framework.generics import CreateAPIView +from rest_framework.generics import CreateAPIView, RetrieveAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.serializers import ValidationError +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 users.models.user import User from .. import errors from .. import serializers +from ..const import MFA_FACE_CONTEXT_CACHE_KEY_PREFIX, MFA_FACE_SESSION_KEY from ..errors import SessionEmptyError from ..mixins import AuthMixin logger = get_logger(__name__) __all__ = [ - 'MFAChallengeVerifyApi', 'MFASendCodeApi' + 'MFAChallengeVerifyApi', 'MFASendCodeApi', + 'MFAFaceCallbackApi', 'MFAFaceContextApi' ] +class MFAFaceCallbackApi(AuthMixin, CreateAPIView): + permission_classes = (IsServiceAccount,) + serializer_class = serializers.MFAFaceCallbackSerializer + + def perform_create(self, serializer): + token = serializer.validated_data.get('token') + context = self._get_context_from_cache(token) + + if not serializer.validated_data.get('success', False): + self._update_context_with_error( + context, + serializer.validated_data.get('error_message', 'Unknown error') + ) + return Response(status=200) + + face_code = serializer.validated_data.get('face_code') + 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) + return Response(status=200) + + @staticmethod + def get_face_cache_key(token): + return f"{MFA_FACE_CONTEXT_CACHE_KEY_PREFIX}_{token}" + + def _get_context_from_cache(self, token): + cache_key = self.get_face_cache_key(token) + context = cache.get(cache_key) + if not context: + raise ValidationError({'error': "token not exists or expired"}) + return context + + def _update_context_with_error(self, context, error_message): + context.update({ + 'is_finished': True, + 'success': False, + 'error_message': error_message, + }) + self._update_cache(context) + + def _update_cache(self, context): + cache_key = self.get_face_cache_key(context['token']) + cache.set(cache_key, context, 3600) + + def _handle_success(self, context, face_code): + context.update({ + 'is_finished': True, + 'success': True, + 'face_code': face_code + }) + self._update_cache(context) + + +class MFAFaceContextApi(AuthMixin, RetrieveAPIView, CreateAPIView): + permission_classes = (AllowAny,) + face_token_session_key = MFA_FACE_SESSION_KEY + + @staticmethod + def get_face_cache_key(token): + return f"{MFA_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) + self.request.session[self.face_token_session_key] = token + return token + + 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') + + cache_key = self.get_face_cache_key(token) + context = cache.get(cache_key) + if not context: + raise NotFound({'error': "Token does not exist or has expired."}) + + return Response({ + "is_finished": context.get('is_finished', False), + "success": context.get('success', False), + "error_message": context.get("error_message", '') + }) + + # MFASelectAPi 原来的名字 class MFASendCodeApi(AuthMixin, CreateAPIView): """ diff --git a/apps/authentication/confirm/mfa.py b/apps/authentication/confirm/mfa.py index a9d3dd1ab..6c61bf402 100644 --- a/apps/authentication/confirm/mfa.py +++ b/apps/authentication/confirm/mfa.py @@ -22,5 +22,6 @@ class ConfirmMFA(BaseConfirm): def authenticate(self, secret_key, mfa_type): mfa_backend = self.user.get_mfa_backend_by_type(mfa_type) + mfa_backend.set_request(self.request) ok, msg = mfa_backend.check_code(secret_key) return ok, msg diff --git a/apps/authentication/const.py b/apps/authentication/const.py index 1e06a4d35..1a27889f1 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -2,7 +2,7 @@ from django.db.models import TextChoices from authentication.confirm import CONFIRM_BACKENDS from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin -from .mfa import MFAOtp, MFASms, MFARadius, MFACustom +from .mfa import MFAOtp, MFASms, MFARadius, MFAFace, MFACustom RSA_PRIVATE_KEY = 'rsa_private_key' RSA_PUBLIC_KEY = 'rsa_public_key' @@ -35,5 +35,10 @@ class ConfirmType(TextChoices): class MFAType(TextChoices): OTP = MFAOtp.name, MFAOtp.display_name SMS = MFASms.name, MFASms.display_name + Face = MFAFace.name, MFAFace.display_name Radius = MFARadius.name, MFARadius.display_name Custom = MFACustom.name, MFACustom.display_name + + +MFA_FACE_CONTEXT_CACHE_KEY_PREFIX = "MFA_FACE_RECOGNITION_CONTEXT" +MFA_FACE_SESSION_KEY = "mfa_face_token" diff --git a/apps/authentication/mfa/__init__.py b/apps/authentication/mfa/__init__.py index 077b6c12c..29d9e2937 100644 --- a/apps/authentication/mfa/__init__.py +++ b/apps/authentication/mfa/__init__.py @@ -2,3 +2,4 @@ from .otp import MFAOtp, otp_failed_msg from .sms import MFASms from .radius import MFARadius from .custom import MFACustom +from .face import MFAFace \ No newline at end of file diff --git a/apps/authentication/mfa/base.py b/apps/authentication/mfa/base.py index 8bcaaf3a0..8575997d6 100644 --- a/apps/authentication/mfa/base.py +++ b/apps/authentication/mfa/base.py @@ -12,10 +12,14 @@ class BaseMFA(abc.ABC): 因为首页登录时,可能没法获取到一些状态 """ self.user = user + self.request = None def is_authenticated(self): return self.user and self.user.is_authenticated + def set_request(self, request): + self.request = request + @property @abc.abstractmethod def name(self): diff --git a/apps/authentication/mfa/face.py b/apps/authentication/mfa/face.py new file mode 100644 index 000000000..c37eae526 --- /dev/null +++ b/apps/authentication/mfa/face.py @@ -0,0 +1,53 @@ +from django.core.cache import cache + +from authentication.mfa.base import BaseMFA +from django.shortcuts import reverse +from django.utils.translation import gettext_lazy as _ + +from authentication.mixins import MFAFaceMixin +from settings.api import settings + + +class MFAFace(BaseMFA, MFAFaceMixin): + name = "face" + display_name = _('Face Recognition') + placeholder = 'Face Recognition' + + def check_code(self, code): + + assert self.is_authenticated() + + try: + code = self.get_face_code() + if not self.user.check_face(code): + return False, _('Facial comparison failed') + except Exception as e: + return False, "{}:{}".format(_('Facial comparison failed'), str(e)) + return True, '' + + def is_active(self): + if not self.is_authenticated(): + return True + return bool(self.user.face_vector) + + @staticmethod + def global_enabled(): + return settings.XPACK_LICENSE_IS_VALID and settings.FACE_RECOGNITION_ENABLED + + def get_enable_url(self) -> str: + return reverse('authentication:user-face-enable') + + def get_disable_url(self) -> str: + return reverse('authentication:user-face-disable') + + def disable(self): + assert self.is_authenticated() + self.user.face_vector = '' + self.user.save(update_fields=['face_vector']) + + def can_disable(self) -> bool: + return True + + @staticmethod + def help_text_of_enable(): + return _("Frontal Face Recognition") diff --git a/apps/authentication/mfa/radius.py b/apps/authentication/mfa/radius.py index 2055e9776..5f6c7ddc8 100644 --- a/apps/authentication/mfa/radius.py +++ b/apps/authentication/mfa/radius.py @@ -12,7 +12,7 @@ class MFARadius(BaseMFA): display_name = 'Radius' placeholder = _("Radius verification code") - def check_code(self, code): + def check_code(self, code=None): assert self.is_authenticated() backend = RadiusBackend() username = self.user.username diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index f1bbadcac..5f1676f86 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -199,6 +199,45 @@ 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) + + 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.") + + 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 @@ -263,7 +302,6 @@ class MFAMixin: user = user if user else self.get_user_from_session() if not user.mfa_enabled: return - # 监测 MFA 是不是屏蔽了 ip = self.get_request_ip() self.check_mfa_is_block(user.username, ip) @@ -276,6 +314,7 @@ class MFAMixin: elif not mfa_backend.is_active(): msg = backend_error.format(mfa_backend.display_name) else: + mfa_backend.set_request(self.request) ok, msg = mfa_backend.check_code(code) if ok: diff --git a/apps/authentication/serializers/password_mfa.py b/apps/authentication/serializers/password_mfa.py index 3e733b6ec..accf24a89 100644 --- a/apps/authentication/serializers/password_mfa.py +++ b/apps/authentication/serializers/password_mfa.py @@ -8,6 +8,7 @@ from common.serializers.fields import EncryptedField __all__ = [ 'MFAChallengeSerializer', 'MFASelectTypeSerializer', 'PasswordVerifySerializer', 'ResetPasswordCodeSerializer', + 'MFAFaceCallbackSerializer' ] @@ -51,3 +52,16 @@ class MFAChallengeSerializer(serializers.Serializer): def update(self, instance, validated_data): pass + + +class MFAFaceCallbackSerializer(serializers.Serializer): + token = serializers.CharField(required=True, allow_blank=False) + success = serializers.BooleanField(required=True, allow_null=False) + error_message = serializers.CharField(required=False, allow_null=True, allow_blank=True) + face_code = serializers.CharField(required=True) + + 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 new file mode 100644 index 000000000..352646427 --- /dev/null +++ b/apps/authentication/templates/authentication/face_capture.html @@ -0,0 +1,92 @@ +{% extends '_base_only_content.html' %} +{% load i18n %} +{% load static %} + +{% block title %} +
+ {% trans 'Face Recognition' %} +
+{% endblock %} + +{% block content %} +
+ {% csrf_token %} + {% if 'code' in form.errors %} +

{{ form.code.errors.as_text }}

+ {% endif %} + +
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 8f74f2907..70d0f2796 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -33,6 +33,8 @@ urlpatterns = [ path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'), path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'), path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-code'), + path('mfa/face/callback/', api.MFAFaceCallbackApi.as_view(), name='mfa-face-callback'), + path('mfa/face/context/', api.MFAFaceContextApi.as_view(), name='mfa-face-context'), path('password/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index e1ffbea94..4feec7a7b 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'), path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'), path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'), + path('login/mfa/face/capture/', views.UserLoginMFAFaceView.as_view(), name='login-face-capture'), path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'), path('logout/', views.UserLogoutView.as_view(), name='logout'), @@ -73,6 +74,8 @@ urlpatterns = [ path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(), name='user-otp-disable'), + path('profile/face/enable/', users_view.UserFaceEnableView.as_view(), name='user-face-enable'), + path('profile/face/disable/', users_view.UserFaceDisableView.as_view(), name='user-face-disable'), # other authentication protocol path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')), path('openid/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='openid')), diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index c297a3261..a043b3861 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -3,14 +3,16 @@ from __future__ import unicode_literals from django.views.generic.edit import FormView -from django.shortcuts import redirect +from django.shortcuts import redirect, reverse from common.utils import get_logger +from users.views import UserFaceCaptureView from .. import forms, errors, mixins from .utils import redirect_to_guard_view +from ..const import MFAType logger = get_logger(__name__) -__all__ = ['UserLoginMFAView'] +__all__ = ['UserLoginMFAView', 'UserLoginMFAFaceView'] class UserLoginMFAView(mixins.AuthMixin, FormView): @@ -32,10 +34,16 @@ class UserLoginMFAView(mixins.AuthMixin, FormView): return super().get(*args, **kwargs) def form_valid(self, form): - from users.utils import MFABlockUtils code = form.cleaned_data.get('code') mfa_type = form.cleaned_data.get('mfa_type') + if mfa_type == MFAType.Face: + return redirect(reverse('authentication:login-face-capture')) + return self.do_mfa_check(form, code, mfa_type) + + def do_mfa_check(self, form, code, mfa_type): + from users.utils import MFABlockUtils + try: self._do_check_user_mfa(code, mfa_type) user, ip = self.get_user_from_session(), self.get_request_ip() @@ -58,3 +66,7 @@ class UserLoginMFAView(mixins.AuthMixin, FormView): kwargs.update(mfa_context) return kwargs + +class UserLoginMFAFaceView(UserFaceCaptureView, UserLoginMFAView): + def form_valid(self, form): + return self.do_mfa_check(form, self.code, self.mfa_type) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index d711f4bba..408228c06 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -353,6 +353,7 @@ class Config(dict): 'AUTH_OPENID_REALM_NAME': None, 'OPENID_ORG_IDS': [DEFAULT_ID], + # Raidus 认证 'AUTH_RADIUS': False, 'RADIUS_SERVER': 'localhost', @@ -487,6 +488,12 @@ class Config(dict): 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2 'LOGIN_REDIRECT_MSG_ENABLED': True, + + # 人脸识别 + 'FACE_RECOGNITION_ENABLED': False, + 'FACE_RECOGNITION_DISTANCE_THRESHOLD': 0.35, + 'FACE_RECOGNITION_COSINE_THRESHOLD': 0.95, + 'SMS_ENABLED': False, 'SMS_BACKEND': '', 'SMS_CODE_LENGTH': 4, diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index 7e0fe9094..f2cec42b6 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -305,6 +305,11 @@ def get_file_md5(filepath): return m.hexdigest() +# 人脸验证 +FACE_RECOGNITION_ENABLED = CONFIG.FACE_RECOGNITION_ENABLED +FACE_RECOGNITION_DISTANCE_THRESHOLD = CONFIG.FACE_RECOGNITION_DISTANCE_THRESHOLD +FACE_RECOGNITION_COSINE_THRESHOLD = CONFIG.FACE_RECOGNITION_COSINE_THRESHOLD + AUTH_CUSTOM = CONFIG.AUTH_CUSTOM AUTH_CUSTOM_FILE_MD5 = CONFIG.AUTH_CUSTOM_FILE_MD5 AUTH_CUSTOM_FILE_PATH = os.path.join(PROJECT_DIR, 'data', 'auth', 'main.py') @@ -313,11 +318,12 @@ if AUTH_CUSTOM and AUTH_CUSTOM_FILE_MD5 == get_file_md5(AUTH_CUSTOM_FILE_PATH): AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_CUSTOM) MFA_BACKEND_OTP = 'authentication.mfa.otp.MFAOtp' +MFA_BACKEND_FACE = 'authentication.mfa.face.MFAFace' MFA_BACKEND_RADIUS = 'authentication.mfa.radius.MFARadius' MFA_BACKEND_SMS = 'authentication.mfa.sms.MFASms' MFA_BACKEND_CUSTOM = 'authentication.mfa.custom.MFACustom' -MFA_BACKENDS = [MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS] +MFA_BACKENDS = [MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS, MFA_BACKEND_FACE] MFA_CUSTOM = CONFIG.MFA_CUSTOM MFA_CUSTOM_FILE_MD5 = CONFIG.MFA_CUSTOM_FILE_MD5 diff --git a/apps/templates/_mfa_login_field.html b/apps/templates/_mfa_login_field.html index 782f98f00..1731a0989 100644 --- a/apps/templates/_mfa_login_field.html +++ b/apps/templates/_mfa_login_field.html @@ -18,15 +18,19 @@ {% if backend.challenge_required %}challenge-required{% endif %}" style="display: none" > - - {% if backend.challenge_required %} - + {% if backend.challenge_required %} + + {% endif %} {% endif %} {% endfor %} diff --git a/apps/users/migrations/0002_user_face_vector.py b/apps/users/migrations/0002_user_face_vector.py new file mode 100644 index 000000000..f8da20c85 --- /dev/null +++ b/apps/users/migrations/0002_user_face_vector.py @@ -0,0 +1,19 @@ +# Generated by Django 4.1.13 on 2024-11-08 03:33 + +import common.db.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='face_vector', + field=common.db.fields.EncryptTextField(blank=True, max_length=2048, null=True, verbose_name='Face Vector'), + ), + ] diff --git a/apps/users/models/user/__init__.py b/apps/users/models/user/__init__.py index 725377ba1..800c1aae4 100644 --- a/apps/users/models/user/__init__.py +++ b/apps/users/models/user/__init__.py @@ -1,10 +1,14 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # +import base64 +import struct import uuid +import math from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager as _UserManager +from django.core.exceptions import ValidationError from django.db import models from django.shortcuts import reverse from django.utils import timezone @@ -23,13 +27,15 @@ from ._json import JSONFilterMixin from ._role import RoleMixin, SystemRoleManager, OrgRoleManager from ._source import SourceMixin, Source from ._token import TokenMixin +from ._face import FaceMixin logger = get_logger(__file__) __all__ = [ "User", "UserPasswordHistory", "MFAMixin", - "AuthMixin" + "AuthMixin", + "FaceMixin" ] @@ -48,6 +54,7 @@ class User( TokenMixin, RoleMixin, MFAMixin, + FaceMixin, LabeledMixin, JSONFilterMixin, AbstractUser, @@ -133,6 +140,9 @@ class User( slack_id = models.CharField( null=True, default=None, max_length=128, verbose_name=_("Slack") ) + face_vector = fields.EncryptTextField( + null=True, blank=True, max_length=2048, verbose_name=_("Face Vector") + ) date_api_key_last_used = models.DateTimeField( null=True, blank=True, verbose_name=_("Date api key used") ) diff --git a/apps/users/models/user/_face.py b/apps/users/models/user/_face.py new file mode 100644 index 000000000..22bcc7c23 --- /dev/null +++ b/apps/users/models/user/_face.py @@ -0,0 +1,56 @@ +import base64 +import struct + +import math +from django.conf import settings +from django.core.exceptions import ValidationError + +from common.utils import ( + get_logger, +) + +logger = get_logger(__file__) + + +class FaceMixin: + face_vector = None + + def get_face_vector(self) -> list[float]: + if not self.face_vector: + raise ValidationError("Face vector is not set.") + return self._decode_base64_vector(str(self.face_vector)) + + def check_face(self, code) -> 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 + + def compare_euclidean_distance(self, base64_vector: str) -> float: + target_vector = self._decode_base64_vector(base64_vector) + current_vector = self.get_face_vector() + return self._calculate_euclidean_distance(current_vector, target_vector) + + def compare_cosine_similarity(self, base64_vector: str) -> float: + target_vector = self._decode_base64_vector(base64_vector) + current_vector = self.get_face_vector() + return self._calculate_cosine_similarity(current_vector, target_vector) + + @staticmethod + def _decode_base64_vector(base64_vector: str) -> list[float]: + byte_data = base64.b64decode(base64_vector) + return list(struct.unpack('<128d', byte_data)) + + @staticmethod + def _calculate_euclidean_distance(vec1: list[float], vec2: list[float]) -> float: + return sum((x - y) ** 2 for x, y in zip(vec1, vec2)) ** 0.5 + + @staticmethod + def _calculate_cosine_similarity(vec1: list[float], vec2: list[float]) -> float: + dot_product = sum(x * y for x, y in zip(vec1, vec2)) + magnitude_vec1 = math.sqrt(sum(x ** 2 for x in vec1)) + magnitude_vec2 = math.sqrt(sum(y ** 2 for y in vec2)) + if magnitude_vec1 == 0 or magnitude_vec2 == 0: + raise ValueError("Vector magnitude cannot be zero.") + return dot_product / (magnitude_vec1 * magnitude_vec2) diff --git a/apps/users/views/profile/__init__.py b/apps/users/views/profile/__init__.py index 5abff8d9f..43cd4b71b 100644 --- a/apps/users/views/profile/__init__.py +++ b/apps/users/views/profile/__init__.py @@ -5,3 +5,4 @@ from .mfa import * from .otp import * from .reset import * from .pubkey import * +from .face import * \ No newline at end of file diff --git a/apps/users/views/profile/face.py b/apps/users/views/profile/face.py new file mode 100644 index 000000000..c5db60623 --- /dev/null +++ b/apps/users/views/profile/face.py @@ -0,0 +1,62 @@ +from django.contrib.auth import logout as auth_logout +from django.shortcuts import redirect +from django.views.generic import FormView +from django import forms + +from authentication import errors +from authentication.mixins import AuthMixin, MFAFaceMixin + +__all__ = ['UserFaceCaptureView', 'UserFaceEnableView', + 'UserFaceDisableView'] + + +class UserFaceCaptureForm(forms.Form): + code = forms.CharField(label='MFA Code', max_length=128, required=False) + + +class UserFaceCaptureView(AuthMixin, FormView): + template_name = 'authentication/face_capture.html' + form_class = UserFaceCaptureForm + mfa_type = 'face' + code = '' + + def form_valid(self, form): + raise NotImplementedError + + def get_context_data(self, **kwargs): + context = super().get_context_data() + + if not self.get_form().is_bound: + context.update({ + "active": True, + }) + + kwargs.update(context) + return kwargs + + +class UserFaceEnableView(UserFaceCaptureView, MFAFaceMixin): + def form_valid(self, form): + code = self.get_face_code() + + user = self.get_user_from_session() + user.face_vector = code + user.save(update_fields=['face_vector']) + + auth_logout(self.request) + return redirect('authentication:login') + + +class UserFaceDisableView(UserFaceCaptureView): + def form_valid(self, form): + try: + self._do_check_user_mfa(self.code, self.mfa_type) + user = self.get_user_from_session() + user.face_vector = None + user.save(update_fields=['face_vector']) + auth_logout(self.request) + except (errors.MFAFailedError, errors.BlockMFAError) as e: + form.add_error('code', e.msg) + return super().form_invalid(form) + + return redirect('authentication:login') diff --git a/config_example.yml b/config_example.yml index 93024c0a5..386ff462a 100644 --- a/config_example.yml +++ b/config_example.yml @@ -96,4 +96,7 @@ REDIS_PORT: 6379 # 仅允许已存在的用户登录,不允许第三方认证后,自动创建用户 # ONLY_ALLOW_EXIST_USER_AUTH: False - +# 开启人脸识别 +#FACE_RECOGNITION_ENABLED: true +#FACE_RECOGNITION_DISTANCE_THRESHOLD': 0.35 +#FACE_RECOGNITION_COSINE_THRESHOLD': 0.95