mirror of https://github.com/jumpserver/jumpserver
perf: passkey auth auto mfa
parent
8065e04f26
commit
e2830ecdd6
|
@ -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)
|
||||
|
|
|
@ -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')
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -49,4 +49,3 @@ class MFAOtp(BaseMFA):
|
|||
|
||||
def help_text_of_disable(self):
|
||||
return ''
|
||||
|
||||
|
|
|
@ -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")
|
|
@ -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/";
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue