feat: 增加人脸识别功能

pull/14440/head
Aaron3S 2 weeks ago committed by Bryan
parent 5142f0340c
commit 86273865c8

@ -1,29 +1,128 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import uuid
from django.core.cache import cache
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import exceptions 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.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
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.utils import get_logger from common.utils import get_logger
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
from ..errors import SessionEmptyError from ..errors import SessionEmptyError
from ..mixins import AuthMixin from ..mixins import AuthMixin
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = [ __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 原来的名字 # MFASelectAPi 原来的名字
class MFASendCodeApi(AuthMixin, CreateAPIView): class MFASendCodeApi(AuthMixin, CreateAPIView):
""" """

@ -22,5 +22,6 @@ class ConfirmMFA(BaseConfirm):
def authenticate(self, secret_key, mfa_type): def authenticate(self, secret_key, mfa_type):
mfa_backend = self.user.get_mfa_backend_by_type(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) ok, msg = mfa_backend.check_code(secret_key)
return ok, msg return ok, msg

@ -2,7 +2,7 @@ from django.db.models import TextChoices
from authentication.confirm import CONFIRM_BACKENDS from authentication.confirm import CONFIRM_BACKENDS
from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin 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_PRIVATE_KEY = 'rsa_private_key'
RSA_PUBLIC_KEY = 'rsa_public_key' RSA_PUBLIC_KEY = 'rsa_public_key'
@ -35,5 +35,10 @@ class ConfirmType(TextChoices):
class MFAType(TextChoices): class MFAType(TextChoices):
OTP = MFAOtp.name, MFAOtp.display_name OTP = MFAOtp.name, MFAOtp.display_name
SMS = MFASms.name, MFASms.display_name SMS = MFASms.name, MFASms.display_name
Face = MFAFace.name, MFAFace.display_name
Radius = MFARadius.name, MFARadius.display_name Radius = MFARadius.name, MFARadius.display_name
Custom = MFACustom.name, MFACustom.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"

@ -2,3 +2,4 @@ from .otp import MFAOtp, otp_failed_msg
from .sms import MFASms from .sms import MFASms
from .radius import MFARadius from .radius import MFARadius
from .custom import MFACustom from .custom import MFACustom
from .face import MFAFace

@ -12,10 +12,14 @@ class BaseMFA(abc.ABC):
因为首页登录时可能没法获取到一些状态 因为首页登录时可能没法获取到一些状态
""" """
self.user = user self.user = user
self.request = None
def is_authenticated(self): def is_authenticated(self):
return self.user and self.user.is_authenticated return self.user and self.user.is_authenticated
def set_request(self, request):
self.request = request
@property @property
@abc.abstractmethod @abc.abstractmethod
def name(self): def name(self):

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

@ -12,7 +12,7 @@ class MFARadius(BaseMFA):
display_name = 'Radius' display_name = 'Radius'
placeholder = _("Radius verification code") placeholder = _("Radius verification code")
def check_code(self, code): def check_code(self, code=None):
assert self.is_authenticated() assert self.is_authenticated()
backend = RadiusBackend() backend = RadiusBackend()
username = self.user.username username = self.user.username

@ -199,6 +199,45 @@ 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)
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: class MFAMixin:
request: Request request: Request
get_user_from_session: Callable get_user_from_session: Callable
@ -263,7 +302,6 @@ class MFAMixin:
user = user if user else self.get_user_from_session() user = user if user else self.get_user_from_session()
if not user.mfa_enabled: if not user.mfa_enabled:
return return
# 监测 MFA 是不是屏蔽了 # 监测 MFA 是不是屏蔽了
ip = self.get_request_ip() ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip) self.check_mfa_is_block(user.username, ip)
@ -276,6 +314,7 @@ class MFAMixin:
elif not mfa_backend.is_active(): elif not mfa_backend.is_active():
msg = backend_error.format(mfa_backend.display_name) msg = backend_error.format(mfa_backend.display_name)
else: else:
mfa_backend.set_request(self.request)
ok, msg = mfa_backend.check_code(code) ok, msg = mfa_backend.check_code(code)
if ok: if ok:

@ -8,6 +8,7 @@ from common.serializers.fields import EncryptedField
__all__ = [ __all__ = [
'MFAChallengeSerializer', 'MFASelectTypeSerializer', 'MFAChallengeSerializer', 'MFASelectTypeSerializer',
'PasswordVerifySerializer', 'ResetPasswordCodeSerializer', 'PasswordVerifySerializer', 'ResetPasswordCodeSerializer',
'MFAFaceCallbackSerializer'
] ]
@ -51,3 +52,16 @@ class MFAChallengeSerializer(serializers.Serializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
pass 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

@ -0,0 +1,92 @@
{% extends '_base_only_content.html' %}
{% load i18n %}
{% load static %}
{% block title %}
<div style="text-align: center">
{% trans 'Face Recognition' %}
</div>
{% endblock %}
{% block content %}
<form class="m-t" role="form" method="post" action="">
{% csrf_token %}
{% if 'code' in form.errors %}
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
{% endif %}
<button id="submit_button" type="submit" style="display: none"></button>
</form>
<div id="iframe_container"
style="display: none; justify-content: center; align-items: center; height: 800px; width: 100%; background-color: #0a6aa1">
<iframe
title="face capture"
id="face_capture_iframe"
allow="camera"
sandbox="allow-scripts allow-same-origin"
style="width: 100%; height: 100%;border: none;">
</iframe>
</div>
<div id="retry_container" style="text-align: center; margin-top: 20px; display: none;">
<button id="retry_button" class="btn btn-primary">{% trans 'Retry' %}</button>
</div>
<script>
$(document).ready(function () {
const apiUrl = "{% url 'api-auth:mfa-face-context' %}";
const faceCaptureUrl = "/faceliving/capture";
let token;
function createFaceCaptureToken() {
const csrf = getCookie('jms_csrftoken');
$.ajax({
url: apiUrl,
method: 'POST',
headers: {
'X-CSRFToken': csrf
},
success: function (data) {
token = data.token;
$('#iframe_container').show();
$('#face_capture_iframe').attr('src', `${faceCaptureUrl}?token=${token}`);
startCheckingStatus();
},
error: function (error) {
$('#retry_container').show();
}
});
}
function startCheckingStatus() {
const interval = 1000;
const timer = setInterval(function () {
$.ajax({
url: `${apiUrl}?token=${token}`,
method: 'GET',
success: function (data) {
if (data.is_finished) {
clearInterval(timer);
$('#submit_button').click();
}
},
error: function (error) {
console.error('API request failed:', error);
}
});
}, interval);
}
const active = "{{ active }}";
if (active) {
createFaceCaptureToken();
} else {
$('#retry_container').show();
}
$('#retry_button').on('click', function () {
window.location.href = "{% url 'authentication:login-face-capture' %}";
});
});
</script>
{% endblock %}

@ -33,6 +33,8 @@ urlpatterns = [
path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'), path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'), path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'),
path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-code'), 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/reset-code/', api.UserResetPasswordSendCodeApi.as_view(), name='reset-password-code'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), 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'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),

@ -14,6 +14,7 @@ urlpatterns = [
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'), path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'), path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'),
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'), 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('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
path('logout/', views.UserLogoutView.as_view(), name='logout'), path('logout/', views.UserLogoutView.as_view(), name='logout'),
@ -73,6 +74,8 @@ urlpatterns = [
path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(), path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
name='user-otp-disable'), 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 # other authentication protocol
path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')), path('cas/', include(('authentication.backends.cas.urls', 'authentication'), namespace='cas')),
path('openid/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='openid')), path('openid/', include(('authentication.backends.oidc.urls', 'authentication'), namespace='openid')),

@ -3,14 +3,16 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.views.generic.edit import FormView 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 common.utils import get_logger
from users.views import UserFaceCaptureView
from .. import forms, errors, mixins from .. import forms, errors, mixins
from .utils import redirect_to_guard_view from .utils import redirect_to_guard_view
from ..const import MFAType
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = ['UserLoginMFAView'] __all__ = ['UserLoginMFAView', 'UserLoginMFAFaceView']
class UserLoginMFAView(mixins.AuthMixin, FormView): class UserLoginMFAView(mixins.AuthMixin, FormView):
@ -32,10 +34,16 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
from users.utils import MFABlockUtils
code = form.cleaned_data.get('code') code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type') 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: try:
self._do_check_user_mfa(code, mfa_type) self._do_check_user_mfa(code, mfa_type)
user, ip = self.get_user_from_session(), self.get_request_ip() user, ip = self.get_user_from_session(), self.get_request_ip()
@ -58,3 +66,7 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
kwargs.update(mfa_context) kwargs.update(mfa_context)
return kwargs return kwargs
class UserLoginMFAFaceView(UserFaceCaptureView, UserLoginMFAView):
def form_valid(self, form):
return self.do_mfa_check(form, self.code, self.mfa_type)

@ -353,6 +353,7 @@ class Config(dict):
'AUTH_OPENID_REALM_NAME': None, 'AUTH_OPENID_REALM_NAME': None,
'OPENID_ORG_IDS': [DEFAULT_ID], 'OPENID_ORG_IDS': [DEFAULT_ID],
# Raidus 认证 # Raidus 认证
'AUTH_RADIUS': False, 'AUTH_RADIUS': False,
'RADIUS_SERVER': 'localhost', 'RADIUS_SERVER': 'localhost',
@ -487,6 +488,12 @@ class Config(dict):
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2
'LOGIN_REDIRECT_MSG_ENABLED': True, '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_ENABLED': False,
'SMS_BACKEND': '', 'SMS_BACKEND': '',
'SMS_CODE_LENGTH': 4, 'SMS_CODE_LENGTH': 4,

@ -305,6 +305,11 @@ def get_file_md5(filepath):
return m.hexdigest() 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 = CONFIG.AUTH_CUSTOM
AUTH_CUSTOM_FILE_MD5 = CONFIG.AUTH_CUSTOM_FILE_MD5 AUTH_CUSTOM_FILE_MD5 = CONFIG.AUTH_CUSTOM_FILE_MD5
AUTH_CUSTOM_FILE_PATH = os.path.join(PROJECT_DIR, 'data', 'auth', 'main.py') 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) AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_CUSTOM)
MFA_BACKEND_OTP = 'authentication.mfa.otp.MFAOtp' MFA_BACKEND_OTP = 'authentication.mfa.otp.MFAOtp'
MFA_BACKEND_FACE = 'authentication.mfa.face.MFAFace'
MFA_BACKEND_RADIUS = 'authentication.mfa.radius.MFARadius' MFA_BACKEND_RADIUS = 'authentication.mfa.radius.MFARadius'
MFA_BACKEND_SMS = 'authentication.mfa.sms.MFASms' MFA_BACKEND_SMS = 'authentication.mfa.sms.MFASms'
MFA_BACKEND_CUSTOM = 'authentication.mfa.custom.MFACustom' 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 = CONFIG.MFA_CUSTOM
MFA_CUSTOM_FILE_MD5 = CONFIG.MFA_CUSTOM_FILE_MD5 MFA_CUSTOM_FILE_MD5 = CONFIG.MFA_CUSTOM_FILE_MD5

@ -18,15 +18,19 @@
{% if backend.challenge_required %}challenge-required{% endif %}" {% if backend.challenge_required %}challenge-required{% endif %}"
style="display: none" style="display: none"
> >
<input type="text" class="form-control input-style"
placeholder="{{ backend.placeholder }}" {% if backend.name == 'face' %}
> {% else %}
{% if backend.challenge_required %} <input type="text" class="form-control input-style"
<button class="btn btn-primary full-width btn-challenge" placeholder="{{ backend.placeholder }}"
type='button' onclick="sendChallengeCode(this)"
> >
{% trans 'Send' %} {% if backend.challenge_required %}
</button> <button class="btn btn-primary full-width btn-challenge"
type='button' onclick="sendChallengeCode(this)"
>
{% trans 'Send' %}
</button>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}

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

@ -1,10 +1,14 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import base64
import struct
import uuid import uuid
import math
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager as _UserManager from django.contrib.auth.models import AbstractUser, UserManager as _UserManager
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.shortcuts import reverse from django.shortcuts import reverse
from django.utils import timezone from django.utils import timezone
@ -23,13 +27,15 @@ from ._json import JSONFilterMixin
from ._role import RoleMixin, SystemRoleManager, OrgRoleManager from ._role import RoleMixin, SystemRoleManager, OrgRoleManager
from ._source import SourceMixin, Source from ._source import SourceMixin, Source
from ._token import TokenMixin from ._token import TokenMixin
from ._face import FaceMixin
logger = get_logger(__file__) logger = get_logger(__file__)
__all__ = [ __all__ = [
"User", "User",
"UserPasswordHistory", "UserPasswordHistory",
"MFAMixin", "MFAMixin",
"AuthMixin" "AuthMixin",
"FaceMixin"
] ]
@ -48,6 +54,7 @@ class User(
TokenMixin, TokenMixin,
RoleMixin, RoleMixin,
MFAMixin, MFAMixin,
FaceMixin,
LabeledMixin, LabeledMixin,
JSONFilterMixin, JSONFilterMixin,
AbstractUser, AbstractUser,
@ -133,6 +140,9 @@ class User(
slack_id = models.CharField( slack_id = models.CharField(
null=True, default=None, max_length=128, verbose_name=_("Slack") 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( date_api_key_last_used = models.DateTimeField(
null=True, blank=True, verbose_name=_("Date api key used") null=True, blank=True, verbose_name=_("Date api key used")
) )

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

@ -5,3 +5,4 @@ from .mfa import *
from .otp import * from .otp import *
from .reset import * from .reset import *
from .pubkey import * from .pubkey import *
from .face import *

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

@ -96,4 +96,7 @@ REDIS_PORT: 6379
# 仅允许已存在的用户登录,不允许第三方认证后,自动创建用户 # 仅允许已存在的用户登录,不允许第三方认证后,自动创建用户
# ONLY_ALLOW_EXIST_USER_AUTH: False # ONLY_ALLOW_EXIST_USER_AUTH: False
# 开启人脸识别
#FACE_RECOGNITION_ENABLED: true
#FACE_RECOGNITION_DISTANCE_THRESHOLD': 0.35
#FACE_RECOGNITION_COSINE_THRESHOLD': 0.95

Loading…
Cancel
Save