perf: passkey auth auto mfa

pull/15306/head
老广 2025-05-07 16:24:39 +08:00 committed by GitHub
parent 8065e04f26
commit e2830ecdd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 192 additions and 105 deletions

View File

@ -1,9 +1,12 @@
import time
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
from common.api import JMSModelViewSet from common.api import JMSModelViewSet
@ -44,6 +47,9 @@ class PasskeyViewSet(AuthMixin, FlashMessageMixin, JMSModelViewSet):
@action(methods=['get'], detail=False, url_path='login', permission_classes=[AllowAny]) @action(methods=['get'], detail=False, url_path='login', permission_classes=[AllowAny])
def login(self, request): def login(self, request):
confirm_mfa = request.GET.get('mfa')
if confirm_mfa:
request.session['passkey_confirm_mfa'] = '1'
return render(request, 'authentication/passkey.html', {}) return render(request, 'authentication/passkey.html', {})
def redirect_to_error(self, error): def redirect_to_error(self, error):
@ -64,8 +70,16 @@ class PasskeyViewSet(AuthMixin, FlashMessageMixin, JMSModelViewSet):
if not user: if not user:
return self.redirect_to_error(_('Auth failed')) return self.redirect_to_error(_('Auth failed'))
confirm_mfa = request.session.get('passkey_confirm_mfa')
if confirm_mfa:
request.session['CONFIRM_LEVEL'] = ConfirmType.values.index('mfa') + 1
request.session['CONFIRM_TIME'] = int(time.time())
request.session['passkey_confirm_mfa'] = ''
return Response('ok')
try: try:
self.check_oauth2_auth(user, settings.AUTH_BACKEND_PASSKEY) self.check_oauth2_auth(user, settings.AUTH_BACKEND_PASSKEY)
self.mark_mfa_ok('passkey', user)
return self.redirect_to_guard_view() return self.redirect_to_guard_view()
except Exception as e: except Exception as e:
msg = getattr(e, 'msg', '') or str(e) msg = getattr(e, 'msg', '') or str(e)

View File

@ -34,6 +34,7 @@ class MFAType(TextChoices):
Email = 'email', _('Email') Email = 'email', _('Email')
Face = 'face', _('Face Recognition') Face = 'face', _('Face Recognition')
Radius = 'otp_radius', _('Radius') Radius = 'otp_radius', _('Radius')
Passkey = 'passkey', _('Passkey')
Custom = 'mfa_custom', _('Custom') Custom = 'mfa_custom', _('Custom')

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
class BaseMFA(abc.ABC): class BaseMFA(abc.ABC):
placeholder = _('Please input security code') placeholder = _('Please input security code')
skip_cache_check = False skip_cache_check = False
has_code = True
def __init__(self, user): def __init__(self, user):
""" """

View File

@ -11,6 +11,7 @@ class MFAFace(BaseMFA, AuthFaceMixin):
display_name = MFAType.Face.name display_name = MFAType.Face.name
placeholder = 'Face Recognition' placeholder = 'Face Recognition'
skip_cache_check = True skip_cache_check = True
has_code = False
def _check_code(self, code): def _check_code(self, code):
assert self.is_authenticated() assert self.is_authenticated()

View File

@ -49,4 +49,3 @@ class MFAOtp(BaseMFA):
def help_text_of_disable(self): def help_text_of_disable(self):
return '' return ''

View File

@ -0,0 +1,46 @@
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from authentication.mfa.base import BaseMFA
from ..const import MFAType
class MFAPasskey(BaseMFA):
name = MFAType.Passkey.value
display_name = MFAType.Passkey.name
placeholder = 'Passkey'
has_code = False
def _check_code(self, code):
assert self.is_authenticated()
return False, ''
def is_active(self):
if not self.is_authenticated():
return True
return self.user.passkey_set.count()
@staticmethod
def global_enabled():
return settings.AUTH_PASSKEY
def get_enable_url(self) -> str:
return '/ui/#/profile/passkeys'
def get_disable_url(self) -> str:
return '/ui/#/profile/passkeys'
def disable(self):
pass
def can_disable(self) -> bool:
return False
@staticmethod
def help_text_of_enable():
return _("Using passkey as MFA")
@staticmethod
def help_text_of_disable():
return _("Using passkey as MFA")

View File

@ -5,12 +5,13 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Login passkey</title> <title>Login passkey</title>
<script src="{% static "js/jquery-3.6.1.min.js" %}?_=9"></script> <script src="{% static 'js/jquery-3.6.1.min.js' %}?_=9"></script>
</head> </head>
<body> <body>
<form action='{% url 'api-auth:passkey-auth' %}' method="post" id="loginForm"> <form action="{% url 'api-auth:passkey-auth' %}" method="post" id="loginForm">
{% csrf_token %}
<input type="hidden" name="passkeys" id="passkeys"/> <input type="hidden" name="passkeys" id="passkeys"/>
</form> </form>
</body> </body>
<script> <script>
const loginUrl = "/core/auth/login/"; const loginUrl = "/core/auth/login/";

View File

@ -5,11 +5,14 @@ from datetime import datetime, timedelta
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from django.conf import settings from django.conf import settings
from django.shortcuts import reverse
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from audits.models import UserLoginLog from audits.models import UserLoginLog
from common.utils import get_ip_city, get_request_ip from common.utils import get_ip_city, get_request_ip
from common.utils import get_logger, get_object_or_none from common.utils import get_logger, get_object_or_none
from common.utils import static_or_direct
from users.models import User from users.models import User
from .notifications import DifferentCityLoginMessage from .notifications import DifferentCityLoginMessage
@ -75,3 +78,72 @@ def check_user_property_is_correct(username, **properties):
user = None user = None
break break
return user return user
def get_auth_methods():
return [
{
'name': 'OpenID',
'enabled': settings.AUTH_OPENID,
'url': reverse('authentication:openid:login'),
'logo': static('img/login_oidc_logo.png'),
'auto_redirect': True # 是否支持自动重定向
},
{
'name': 'CAS',
'enabled': settings.AUTH_CAS,
'url': reverse('authentication:cas:cas-login'),
'logo': static('img/login_cas_logo.png'),
'auto_redirect': True
},
{
'name': 'SAML2',
'enabled': settings.AUTH_SAML2,
'url': reverse('authentication:saml2:saml2-login'),
'logo': static('img/login_saml2_logo.png'),
'auto_redirect': True
},
{
'name': settings.AUTH_OAUTH2_PROVIDER,
'enabled': settings.AUTH_OAUTH2,
'url': reverse('authentication:oauth2:login'),
'logo': static_or_direct(settings.AUTH_OAUTH2_LOGO_PATH),
'auto_redirect': True
},
{
'name': _('WeCom'),
'enabled': settings.AUTH_WECOM,
'url': reverse('authentication:wecom-qr-login'),
'logo': static('img/login_wecom_logo.png'),
},
{
'name': _('DingTalk'),
'enabled': settings.AUTH_DINGTALK,
'url': reverse('authentication:dingtalk-qr-login'),
'logo': static('img/login_dingtalk_logo.png')
},
{
'name': _('FeiShu'),
'enabled': settings.AUTH_FEISHU,
'url': reverse('authentication:feishu-qr-login'),
'logo': static('img/login_feishu_logo.png')
},
{
'name': 'Lark',
'enabled': settings.AUTH_LARK,
'url': reverse('authentication:lark-qr-login'),
'logo': static('img/login_lark_logo.png')
},
{
'name': _('Slack'),
'enabled': settings.AUTH_SLACK,
'url': reverse('authentication:slack-qr-login'),
'logo': static('img/login_slack_logo.png')
},
{
'name': _("Passkey"),
'enabled': settings.AUTH_PASSKEY,
'url': reverse('api-auth:passkey-login'),
'logo': static('img/login_passkey.png')
}
]

View File

@ -14,7 +14,6 @@ from django.contrib.auth import login as auth_login, logout as auth_logout
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import reverse, redirect from django.shortcuts import reverse, redirect
from django.templatetags.static import static
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _, get_language from django.utils.translation import gettext as _, get_language
@ -25,13 +24,14 @@ from django.views.generic.base import TemplateView, RedirectView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from common.const import Language from common.const import Language
from common.utils import FlashMessageUtil, static_or_direct, safe_next_url from common.utils import FlashMessageUtil, safe_next_url
from users.utils import ( from users.utils import (
redirect_user_first_login_or_index redirect_user_first_login_or_index
) )
from .. import mixins, errors from .. import mixins, errors
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
from ..forms import get_user_login_form_cls from ..forms import get_user_login_form_cls
from ..utils import get_auth_methods
__all__ = [ __all__ = [
'UserLoginView', 'UserLogoutView', 'UserLoginView', 'UserLogoutView',
@ -46,73 +46,17 @@ class UserLoginContextMixin:
def get_support_auth_methods(self): def get_support_auth_methods(self):
query_string = self.request.GET.urlencode() query_string = self.request.GET.urlencode()
auth_methods = [ all_methods = get_auth_methods()
{ methods = []
'name': 'OpenID', for method in all_methods:
'enabled': settings.AUTH_OPENID, method = method.copy()
'url': f"{reverse('authentication:openid:login')}?{query_string}", if not method.get('enabled', False):
'logo': static('img/login_oidc_logo.png'), continue
'auto_redirect': True # 是否支持自动重定向 url = method.get('url', '')
}, if query_string and url:
{ method['url'] = '{}?{}'.format(url, query_string)
'name': 'CAS', methods.append(method)
'enabled': settings.AUTH_CAS, return methods
'url': f"{reverse('authentication:cas:cas-login')}?{query_string}",
'logo': static('img/login_cas_logo.png'),
'auto_redirect': True
},
{
'name': 'SAML2',
'enabled': settings.AUTH_SAML2,
'url': f"{reverse('authentication:saml2:saml2-login')}?{query_string}",
'logo': static('img/login_saml2_logo.png'),
'auto_redirect': True
},
{
'name': settings.AUTH_OAUTH2_PROVIDER,
'enabled': settings.AUTH_OAUTH2,
'url': f"{reverse('authentication:oauth2:login')}?{query_string}",
'logo': static_or_direct(settings.AUTH_OAUTH2_LOGO_PATH),
'auto_redirect': True
},
{
'name': _('WeCom'),
'enabled': settings.AUTH_WECOM,
'url': f"{reverse('authentication:wecom-qr-login')}?{query_string}",
'logo': static('img/login_wecom_logo.png'),
},
{
'name': _('DingTalk'),
'enabled': settings.AUTH_DINGTALK,
'url': f"{reverse('authentication:dingtalk-qr-login')}?{query_string}",
'logo': static('img/login_dingtalk_logo.png')
},
{
'name': _('FeiShu'),
'enabled': settings.AUTH_FEISHU,
'url': f"{reverse('authentication:feishu-qr-login')}?{query_string}",
'logo': static('img/login_feishu_logo.png')
},
{
'name': 'Lark',
'enabled': settings.AUTH_LARK,
'url': f"{reverse('authentication:lark-qr-login')}?{query_string}",
'logo': static('img/login_lark_logo.png')
},
{
'name': _('Slack'),
'enabled': settings.AUTH_SLACK,
'url': f"{reverse('authentication:slack-qr-login')}?{query_string}",
'logo': static('img/login_slack_logo.png')
},
{
'name': _("Passkey"),
'enabled': settings.AUTH_PASSKEY,
'url': f"{reverse('api-auth:passkey-login')}?{query_string}",
'logo': static('img/login_passkey.png')
}
]
return [method for method in auth_methods if method['enabled']]
@staticmethod @staticmethod
def get_support_langs(): def get_support_langs():

View File

@ -40,6 +40,8 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
if mfa_type == MFAType.Face: if mfa_type == MFAType.Face:
return redirect(reverse('authentication:login-face-capture')) return redirect(reverse('authentication:login-face-capture'))
elif mfa_type == MFAType.Passkey:
return redirect('/api/v1/authentication/passkeys/login/')
return self.do_mfa_check(form, code, mfa_type) return self.do_mfa_check(form, code, mfa_type)
def do_mfa_check(self, form, code, mfa_type): def do_mfa_check(self, form, code, mfa_type):

View File

@ -328,9 +328,13 @@ 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_EMAIL = 'authentication.mfa.email.MFAEmail' MFA_BACKEND_EMAIL = 'authentication.mfa.email.MFAEmail'
MFA_BACKEND_PASSKEY = 'authentication.mfa.passkey.MFAPasskey'
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_BACKEND_FACE, MFA_BACKEND_EMAIL] MFA_BACKENDS = [
MFA_BACKEND_OTP, MFA_BACKEND_RADIUS, MFA_BACKEND_SMS,
MFA_BACKEND_PASSKEY, MFA_BACKEND_FACE, MFA_BACKEND_EMAIL
]
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

@ -37,11 +37,15 @@ class ComponentI18nApi(RetrieveAPIView):
def retrieve(self, request, *args, **kwargs): def retrieve(self, request, *args, **kwargs):
name = kwargs.get('name') name = kwargs.get('name')
lang = request.query_params.get('lang') lang = request.query_params.get('lang')
flat = request.query_params.get('flat', '1')
data = self.get_component_translations(name) data = self.get_component_translations(name)
if lang:
if not lang:
return Response(data)
if lang not in Language.choices:
lang = 'en'
code = Language.to_internal_code(lang, with_filename=True) code = Language.to_internal_code(lang, with_filename=True)
data = data.get(code) or {} data = data.get(code) or {}
flat = request.query_params.get('flat', '1')
if flat == '0': if flat == '0':
# 这里要使用原始的 lang, lina 会 merge # 这里要使用原始的 lang, lina 会 merge
data = {lang: data} data = {lang: data}

View File

@ -18,9 +18,7 @@
{% if backend.challenge_required %}challenge-required{% endif %}" {% if backend.challenge_required %}challenge-required{% endif %}"
style="display: none" style="display: none"
> >
{% if backend.has_code %}
{% if backend.name == 'face' %}
{% else %}
<input type="text" class="form-control input-style" <input type="text" class="form-control input-style"
placeholder="{{ backend.placeholder }}" placeholder="{{ backend.placeholder }}"
> >
@ -121,7 +119,7 @@
}) })
} }
function onError (responseText, responseJson, status) { function onError(responseText, responseJson, status) {
setTimeout(function () { setTimeout(function () {
toastr.error(responseJson.detail || responseJson.error); toastr.error(responseJson.detail || responseJson.error);
}); });