mirror of https://github.com/jumpserver/jumpserver
feat: 增加人脸识别功能
parent
5142f0340c
commit
86273865c8
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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):
|
||||
|
|
|
@ -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'
|
||||
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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/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'),
|
||||
|
|
|
@ -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')),
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -18,15 +18,19 @@
|
|||
{% if backend.challenge_required %}challenge-required{% endif %}"
|
||||
style="display: none"
|
||||
>
|
||||
<input type="text" class="form-control input-style"
|
||||
placeholder="{{ backend.placeholder }}"
|
||||
>
|
||||
{% if backend.challenge_required %}
|
||||
<button class="btn btn-primary full-width btn-challenge"
|
||||
type='button' onclick="sendChallengeCode(this)"
|
||||
|
||||
{% if backend.name == 'face' %}
|
||||
{% else %}
|
||||
<input type="text" class="form-control input-style"
|
||||
placeholder="{{ backend.placeholder }}"
|
||||
>
|
||||
{% trans 'Send' %}
|
||||
</button>
|
||||
{% if backend.challenge_required %}
|
||||
<button class="btn btn-primary full-width btn-challenge"
|
||||
type='button' onclick="sendChallengeCode(this)"
|
||||
>
|
||||
{% trans 'Send' %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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
|
||||
# -*- 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")
|
||||
)
|
||||
|
|
|
@ -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 .reset 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
|
||||
|
||||
|
||||
# 开启人脸识别
|
||||
#FACE_RECOGNITION_ENABLED: true
|
||||
#FACE_RECOGNITION_DISTANCE_THRESHOLD': 0.35
|
||||
#FACE_RECOGNITION_COSINE_THRESHOLD': 0.95
|
||||
|
|
Loading…
Reference in New Issue