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.http import JsonResponse
from django.shortcuts import render
from django.utils.translation import gettext as _
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from authentication.mixins import AuthMixin
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])
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', {})
def redirect_to_error(self, error):
@ -64,8 +70,16 @@ class PasskeyViewSet(AuthMixin, FlashMessageMixin, JMSModelViewSet):
if not user:
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:
self.check_oauth2_auth(user, settings.AUTH_BACKEND_PASSKEY)
self.mark_mfa_ok('passkey', user)
return self.redirect_to_guard_view()
except Exception as e:
msg = getattr(e, 'msg', '') or str(e)

View File

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

View File

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

View File

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

View File

@ -49,4 +49,3 @@ class MFAOtp(BaseMFA):
def help_text_of_disable(self):
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>
<meta charset="UTF-8">
<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>
<body>
<form action='{% url 'api-auth:passkey-auth' %}' method="post" id="loginForm">
<input type="hidden" name="passkeys" id="passkeys"/>
</form>
<form action="{% url 'api-auth:passkey-auth' %}" method="post" id="loginForm">
{% csrf_token %}
<input type="hidden" name="passkeys" id="passkeys"/>
</form>
</body>
<script>
const loginUrl = "/core/auth/login/";

View File

@ -5,11 +5,14 @@ from datetime import datetime, timedelta
from urllib.parse import urljoin, urlparse
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 audits.models import UserLoginLog
from common.utils import get_ip_city, get_request_ip
from common.utils import get_logger, get_object_or_none
from common.utils import static_or_direct
from users.models import User
from .notifications import DifferentCityLoginMessage
@ -75,3 +78,72 @@ def check_user_property_is_correct(username, **properties):
user = None
break
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.http import HttpRequest
from django.shortcuts import reverse, redirect
from django.templatetags.static import static
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
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 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 (
redirect_user_first_login_or_index
)
from .. import mixins, errors
from ..const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY
from ..forms import get_user_login_form_cls
from ..utils import get_auth_methods
__all__ = [
'UserLoginView', 'UserLogoutView',
@ -46,73 +46,17 @@ class UserLoginContextMixin:
def get_support_auth_methods(self):
query_string = self.request.GET.urlencode()
auth_methods = [
{
'name': 'OpenID',
'enabled': settings.AUTH_OPENID,
'url': f"{reverse('authentication:openid:login')}?{query_string}",
'logo': static('img/login_oidc_logo.png'),
'auto_redirect': True # 是否支持自动重定向
},
{
'name': 'CAS',
'enabled': settings.AUTH_CAS,
'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']]
all_methods = get_auth_methods()
methods = []
for method in all_methods:
method = method.copy()
if not method.get('enabled', False):
continue
url = method.get('url', '')
if query_string and url:
method['url'] = '{}?{}'.format(url, query_string)
methods.append(method)
return methods
@staticmethod
def get_support_langs():

View File

@ -40,6 +40,8 @@ class UserLoginMFAView(mixins.AuthMixin, FormView):
if mfa_type == MFAType.Face:
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)
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_SMS = 'authentication.mfa.sms.MFASms'
MFA_BACKEND_EMAIL = 'authentication.mfa.email.MFAEmail'
MFA_BACKEND_PASSKEY = 'authentication.mfa.passkey.MFAPasskey'
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_FILE_MD5 = CONFIG.MFA_CUSTOM_FILE_MD5

View File

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

View File

@ -5,34 +5,32 @@
onchange="selectChange(this.value)"
>
{% for backend in mfa_backends %}
<option value="{{ backend.name }}"
{% if not backend.is_active %} disabled {% endif %}
>
{{ backend.display_name }}
</option>
<option value="{{ backend.name }}"
{% if not backend.is_active %} disabled {% endif %}
>
{{ backend.display_name }}
</option>
{% endfor %}
</select>
<div class="mfa-div">
{% for backend in mfa_backends %}
<div id="mfa-{{ backend.name }}" class="mfa-field
<div id="mfa-{{ backend.name }}" class="mfa-field
{% if backend.challenge_required %}challenge-required{% endif %}"
style="display: none"
style="display: none"
>
{% if backend.has_code %}
<input type="text" class="form-control input-style"
placeholder="{{ backend.placeholder }}"
>
{% if backend.name == 'face' %}
{% else %}
<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)"
>
{% trans 'Send' %}
</button>
{% endif %}
{% endif %}
</div>
{% 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 %}
</div>
@ -121,7 +119,7 @@
})
}
function onError (responseText, responseJson, status) {
function onError(responseText, responseJson, status) {
setTimeout(function () {
toastr.error(responseJson.detail || responseJson.error);
});