feat: 增加人脸识别功能

This commit is contained in:
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 -*-
#
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):
"""

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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