feat: 增加人脸识别功能

pull/14440/head
Aaron3S 2024-11-12 17:28:43 +08:00 committed by Bryan
parent 5142f0340c
commit 86273865c8
22 changed files with 512 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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