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.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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -49,4 +49,3 @@ class MFAOtp(BaseMFA):
|
||||||
|
|
||||||
def help_text_of_disable(self):
|
def help_text_of_disable(self):
|
||||||
return ''
|
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>
|
<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/";
|
||||||
|
|
|
@ -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')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
|
@ -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():
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue