From 63638ed1ce6ba97c6dde6a536f0a3f25804c42b4 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Mon, 18 Oct 2021 18:41:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E9=A1=B5=E7=9A=84=20chanlege?= =?UTF-8?q?=20=E5=92=8C=20MFA=20=E7=BB=9F=E4=B8=80=20(#6989)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 首页的 chanlege 和 MFA 统一 * 登陆样式调整 * mfa bug * q * m * mfa封装组件 前端可修改配置 * perf: 添加翻译 * login css bug * perf: 修改一些风格 * perf: 修改命名 * perf: 修改 mfa code 不是必填 * mfa 前端统一组件 * stash * perf: 统一验证码 Co-authored-by: feng626 <1304903146@qq.com> Co-authored-by: ibuler --- apps/authentication/api/mfa.py | 19 +- apps/authentication/errors.py | 13 + apps/authentication/forms.py | 6 +- apps/authentication/mixins.py | 94 ++- apps/authentication/models.py | 2 +- apps/authentication/sms_verify_code.py | 2 - .../authentication/_captcha_field.html | 4 +- .../templates/authentication/login.html | 54 +- .../templates/authentication/login_otp.html | 71 +-- apps/authentication/urls/api_urls.py | 1 - apps/authentication/views/login.py | 30 +- apps/authentication/views/mfa.py | 30 +- apps/common/message/backends/sms/tencent.py | 1 - apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 1 + apps/locale/zh/LC_MESSAGES/django.po | 545 ++++++++++++++---- apps/settings/serializers/security.py | 35 +- apps/templates/_mfa_otp_login.html | 81 +++ apps/users/models/user.py | 3 +- 19 files changed, 707 insertions(+), 286 deletions(-) create mode 100644 apps/templates/_mfa_otp_login.html diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index f067d5c5c..978c52072 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -2,17 +2,17 @@ # import builtins import time + from django.utils.translation import ugettext as _ from django.conf import settings +from django.shortcuts import get_object_or_404 from rest_framework.permissions import AllowAny from rest_framework.generics import CreateAPIView from rest_framework.serializers import ValidationError from rest_framework.response import Response -from authentication.sms_verify_code import VerifyCodeUtil -from common.exceptions import JMSException -from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser -from users.models.user import MFAType +from common.permissions import IsValidUser, NeedMFAVerify +from users.models.user import MFAType, User from ..serializers import OtpVerifySerializer from .. import serializers from .. import errors @@ -90,6 +90,13 @@ class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView): permission_classes = (AllowAny,) def create(self, request, *args, **kwargs): - user = self.get_user_from_session() + username = request.data.get('username', '') + username = username.strip() + if username: + user = get_object_or_404(User, username=username) + else: + user = self.get_user_from_session() + if not user.mfa_enabled: + raise errors.NotEnableMFAError timeout = user.send_sms_code() - return Response({'code': 'ok','timeout': timeout}) + return Response({'code': 'ok', 'timeout': timeout}) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index 5844eb777..ca190fe2b 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -78,6 +78,7 @@ mfa_type_failed_msg = _( mfa_required_msg = _("MFA required") mfa_unset_msg = _("MFA not set, please set it first") +otp_unset_msg = _("OTP not set, please set it first") login_confirm_required_msg = _("Login confirm required") login_confirm_wait_msg = _("Wait login confirm ticket for accept") login_confirm_error_msg = _("Login confirm ticket was {}") @@ -354,3 +355,15 @@ class NotHaveUpDownLoadPerm(JMSException): status_code = status.HTTP_403_FORBIDDEN code = 'not_have_up_down_load_perm' default_detail = _('No upload or download permission') + + +class NotEnableMFAError(JMSException): + default_detail = mfa_unset_msg + + +class OTPRequiredError(JMSException): + default_detail = otp_unset_msg + + def __init__(self, url, *args, **kwargs): + super().__init__(*args, **kwargs) + self.url = url diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index d4c1375e6..d8533536d 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -43,7 +43,7 @@ class UserLoginForm(forms.Form): class UserCheckOtpCodeForm(forms.Form): - code = forms.CharField(label=_('MFA Code'), max_length=6) + code = forms.CharField(label=_('MFA Code'), max_length=6, required=False) mfa_type = forms.CharField(label=_('MFA type'), max_length=6) @@ -59,7 +59,7 @@ class ChallengeMixin(forms.Form): challenge = forms.CharField( label=_('MFA code'), max_length=6, required=False, widget=forms.TextInput(attrs={ - 'placeholder': _("MFA code"), + 'placeholder': _("Dynamic code"), 'style': 'width: 50%' }) ) @@ -69,6 +69,8 @@ def get_user_login_form_cls(*, captcha=False): bases = [] if settings.SECURITY_LOGIN_CHALLENGE_ENABLED: bases.append(ChallengeMixin) + elif settings.SECURITY_MFA_IN_LOGIN_PAGE: + bases.append(UserCheckOtpCodeForm) elif settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha: bases.append(CaptchaMixin) bases.append(UserLoginForm) diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 07beee3e7..a32ee6b81 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -7,6 +7,7 @@ import time from django.core.cache import cache from django.conf import settings +from django.urls import reverse_lazy from django.contrib import auth from django.utils.translation import ugettext as _ from django.contrib.auth import ( @@ -14,11 +15,11 @@ from django.contrib.auth import ( PermissionDenied, user_login_failed, _clean_credentials ) from django.shortcuts import reverse, redirect -from django.views.generic.edit import FormView from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil from users.models import User, MFAType from users.utils import LoginBlockUtil, MFABlockUtils +from users.exceptions import MFANotEnabled from . import errors from .utils import rsa_decrypt, gen_key_pair from .signals import post_auth_success, post_auth_failed @@ -208,18 +209,20 @@ class AuthMixin(PasswordEncryptionViewMixin): ip = self.get_request_ip() self._set_partial_credential_error(username=username, ip=ip, request=request) - password = password + challenge.strip() if decrypt_passwd: password = self.get_decrypted_password() + password = password + challenge.strip() return username, password, public_key, ip, auto_login def _check_only_allow_exists_user_auth(self, username): # 仅允许预先存在的用户认证 - if settings.ONLY_ALLOW_EXIST_USER_AUTH: - exist = User.objects.filter(username=username).exists() - if not exist: - logger.error(f"Only allow exist user auth, login failed: {username}") - self.raise_credential_error(errors.reason_user_not_exist) + if not settings.ONLY_ALLOW_EXIST_USER_AUTH: + return + + exist = User.objects.filter(username=username).exists() + if not exist: + logger.error(f"Only allow exist user auth, login failed: {username}") + self.raise_credential_error(errors.reason_user_not_exist) def _check_auth_user_is_valid(self, username, password, public_key): user = authenticate(self.request, username=username, password=password, public_key=public_key) @@ -231,6 +234,17 @@ class AuthMixin(PasswordEncryptionViewMixin): self.raise_credential_error(errors.reason_user_inactive) return user + def _check_login_mfa_login_if_need(self, user): + request = self.request + if hasattr(request, 'data'): + data = request.data + else: + data = request.POST + code = data.get('code') + mfa_type = data.get('mfa_type') + if settings.SECURITY_MFA_IN_LOGIN_PAGE and code and mfa_type: + self.check_user_mfa(code, mfa_type, user=user) + def _check_login_acl(self, user, ip): # ACL 限制用户登录 from acls.models import LoginACL @@ -255,8 +269,7 @@ class AuthMixin(PasswordEncryptionViewMixin): def check_user_auth(self, decrypt_passwd=False): self.check_is_block() - request = self.request - username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd=decrypt_passwd) + username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd) self._check_only_allow_exists_user_auth(username) user = self._check_auth_user_is_valid(username, password, public_key) @@ -266,7 +279,11 @@ class AuthMixin(PasswordEncryptionViewMixin): self._check_passwd_is_too_simple(user, password) self._check_passwd_need_update(user) + # 校验login-mfa, 如果登录页面上显示 mfa 的话 + self._check_login_mfa_login_if_need(user) + LoginBlockUtil(username, ip).clean_failed_count() + request = self.request request.session['auth_password'] = 1 request.session['user_id'] = str(user.id) request.session['auto_login'] = auto_login @@ -348,12 +365,11 @@ class AuthMixin(PasswordEncryptionViewMixin): def check_user_mfa_if_need(self, user): if self.request.session.get('auth_mfa'): return - if settings.OTP_IN_RADIUS: return - if not user.mfa_enabled: return + unset, url = user.mfa_enabled_but_not_set() if unset: raise errors.MFAUnsetError(user, self.request, url) @@ -372,19 +388,29 @@ class AuthMixin(PasswordEncryptionViewMixin): self.request.session['auth_mfa_type'] = '' def check_mfa_is_block(self, username, ip, raise_exception=True): - if MFABlockUtils(username, ip).is_block(): - logger.warn('Ip was blocked' + ': ' + username + ':' + ip) - exception = errors.BlockMFAError(username=username, request=self.request, ip=ip) - if raise_exception: - raise exception - else: - return exception + blocked = MFABlockUtils(username, ip).is_block() + if not blocked: + return + logger.warn('Ip was blocked' + ': ' + username + ':' + ip) + exception = errors.BlockMFAError(username=username, request=self.request, ip=ip) + if raise_exception: + raise exception + else: + return exception + + def check_user_mfa(self, code, mfa_type=MFAType.OTP, user=None): + user = user if user else self.get_user_from_session() + if not user.mfa_enabled: + return True + + if not (bool(user.otp_secret_key) and mfa_type == MFAType.OTP): + self.set_passwd_verify_on_session(user) + raise errors.OTPRequiredError(reverse_lazy('authentication:user-otp-enable-bind')) - def check_user_mfa(self, code, mfa_type=MFAType.OTP): - user = self.get_user_from_session() ip = self.get_request_ip() self.check_mfa_is_block(user.username, ip) ok = user.check_mfa(code, mfa_type=mfa_type) + if ok: self.mark_mfa_ok() return @@ -468,3 +494,31 @@ class AuthMixin(PasswordEncryptionViewMixin): if args: guard_url = "%s?%s" % (guard_url, args) return redirect(guard_url) + + @staticmethod + def get_user_mfa_methods(user=None): + otp_enabled = user.otp_secret_key if user else True + # 没有用户时,或者有用户并且有电话配置 + sms_enabled = any([user and user.phone, not user]) \ + and settings.SMS_ENABLED and settings.XPACK_ENABLED + + methods = [ + { + 'name': 'otp', + 'label': 'MFA', + 'enable': otp_enabled, + 'selected': False, + }, + { + 'name': 'sms', + 'label': _('SMS'), + 'enable': sms_enabled, + 'selected': False, + }, + ] + + for item in methods: + if item['enable']: + item['selected'] = True + break + return methods diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 62bd74392..6e7b59e54 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -1,7 +1,7 @@ import uuid from django.utils import timezone -from django.utils.translation import ugettext_lazy as _, ugettext as __ +from django.utils.translation import ugettext_lazy as _ from rest_framework.authtoken.models import Token from django.conf import settings diff --git a/apps/authentication/sms_verify_code.py b/apps/authentication/sms_verify_code.py index 0c3e986ce..4236d9c0d 100644 --- a/apps/authentication/sms_verify_code.py +++ b/apps/authentication/sms_verify_code.py @@ -1,10 +1,8 @@ import random -from django.conf import settings from django.core.cache import cache from django.utils.translation import gettext_lazy as _ -from common.message.backends.sms.alibaba import AlibabaSMS from common.message.backends.sms import SMS from common.utils import get_logger from common.exceptions import JMSException diff --git a/apps/authentication/templates/authentication/_captcha_field.html b/apps/authentication/templates/authentication/_captcha_field.html index a190aacb7..0aa3fc260 100644 --- a/apps/authentication/templates/authentication/_captcha_field.html +++ b/apps/authentication/templates/authentication/_captcha_field.html @@ -5,9 +5,9 @@
{% if audio %} - + {% endif %} -
+
{% include "django/forms/widgets/multiwidget.html" %} diff --git a/apps/authentication/templates/authentication/login.html b/apps/authentication/templates/authentication/login.html index 08591892b..f968e325c 100644 --- a/apps/authentication/templates/authentication/login.html +++ b/apps/authentication/templates/authentication/login.html @@ -10,23 +10,15 @@ {{ JMS_TITLE }} + {% include '_head_css_js.html' %} - - - - - - - - - - + -
+