pref: 优化MFA (#7153)

* perf: 优化mfa 和登录

* perf: stash

* stash

* pref: 基本完成

* perf: remove init function

* perf: 优化命名

* perf: 优化backends

* perf: 基本完成优化

* perf: 修复首页登录时没有 toastr 的问题

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com>
This commit is contained in:
fit2bot 2021-11-10 11:30:48 +08:00 committed by GitHub
parent bac974b4f2
commit 17303c0550
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1373 additions and 977 deletions

View File

@ -1,6 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import builtins
import time import time
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -12,55 +11,76 @@ from rest_framework.serializers import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
from common.permissions import IsValidUser, NeedMFAVerify from common.permissions import IsValidUser, NeedMFAVerify
from users.models.user import MFAType, User from common.utils import get_logger
from users.models.user import User
from ..serializers import OtpVerifySerializer from ..serializers import OtpVerifySerializer
from .. import serializers from .. import serializers
from .. import errors from .. import errors
from ..mfa.otp import MFAOtp
from ..mixins import AuthMixin from ..mixins import AuthMixin
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi'] logger = get_logger(__name__)
__all__ = [
'MFAChallengeVerifyApi', 'UserOtpVerifyApi',
'MFASendCodeApi'
]
class MFASelectTypeApi(AuthMixin, CreateAPIView): # MFASelectAPi 原来的名字
class MFASendCodeApi(AuthMixin, CreateAPIView):
"""
选择 MFA 后对应操作 apikoko 目前在用
"""
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = serializers.MFASelectTypeSerializer serializer_class = serializers.MFASelectTypeSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
username = serializer.validated_data.get('username', '')
mfa_type = serializer.validated_data['type'] mfa_type = serializer.validated_data['type']
if mfa_type == MFAType.SMS_CODE: if not username:
user = self.get_user_from_session() user = self.get_user_from_session()
user.send_sms_code() else:
user = get_object_or_404(User, username=username)
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
if not mfa_backend or not mfa_backend.challenge_required:
raise ValidationError('MFA type not support: {} {}'.format(mfa_type, mfa_backend))
mfa_backend.send_challenge()
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
self.perform_create(serializer)
return Response(serializer.data, status=201)
except Exception as e:
logger.exception(e)
return Response({'error': str(e)}, status=400)
class MFAChallengeApi(AuthMixin, CreateAPIView): class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
serializer_class = serializers.MFAChallengeSerializer serializer_class = serializers.MFAChallengeSerializer
def perform_create(self, serializer): def perform_create(self, serializer):
try: user = self.get_user_from_session()
user = self.get_user_from_session() code = serializer.validated_data.get('code')
code = serializer.validated_data.get('code') mfa_type = serializer.validated_data.get('type', '')
mfa_type = serializer.validated_data.get('type', MFAType.OTP) self._do_check_user_mfa(code, mfa_type, user)
valid = user.check_mfa(code, mfa_type=mfa_type) def create(self, request, *args, **kwargs):
if not valid: try:
self.request.session['auth_mfa'] = '' super().create(request, *args, **kwargs)
raise errors.MFAFailedError( return Response({'msg': 'ok'})
username=user.username, request=self.request, ip=self.get_request_ip()
)
else:
self.request.session['auth_mfa'] = '1'
except errors.AuthFailedError as e: except errors.AuthFailedError as e:
data = {"error": e.error, "msg": e.msg} data = {"error": e.error, "msg": e.msg}
raise ValidationError(data) raise ValidationError(data)
except errors.NeedMoreInfoError as e: except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200) return Response(e.as_data(), status=200)
def create(self, request, *args, **kwargs):
super().create(request, *args, **kwargs)
return Response({'msg': 'ok'})
class UserOtpVerifyApi(CreateAPIView): class UserOtpVerifyApi(CreateAPIView):
permission_classes = (IsValidUser,) permission_classes = (IsValidUser,)
@ -73,30 +93,17 @@ class UserOtpVerifyApi(CreateAPIView):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"] code = serializer.validated_data["code"]
otp = MFAOtp(request.user)
if request.user.check_mfa(code): ok, error = otp.check_code(code)
if ok:
request.session["MFA_VERIFY_TIME"] = int(time.time()) request.session["MFA_VERIFY_TIME"] = int(time.time())
return Response({"ok": "1"}) return Response({"ok": "1"})
else: else:
return Response({"error": _("Code is invalid")}, status=400) return Response({"error": _("Code is invalid") + ", " + error}, status=400)
def get_permissions(self): def get_permissions(self):
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA: if self.request.method.lower() == 'get' \
and settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [NeedMFAVerify] self.permission_classes = [NeedMFAVerify]
return super().get_permissions() return super().get_permissions()
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
def create(self, request, *args, **kwargs):
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})

View File

@ -4,7 +4,7 @@ from rest_framework.response import Response
from authentication.serializers import PasswordVerifySerializer from authentication.serializers import PasswordVerifySerializer
from common.permissions import IsValidUser from common.permissions import IsValidUser
from authentication.mixins import authenticate from authentication.mixins import authenticate
from authentication.errors import PasswdInvalid from authentication.errors import PasswordInvalid
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
@ -20,7 +20,7 @@ class UserPasswordVerifyApi(AuthMixin, CreateAPIView):
user = authenticate(request=request, username=user.username, password=password) user = authenticate(request=request, username=user.username, password=password)
if not user: if not user:
raise PasswdInvalid raise PasswordInvalid
self.set_passwd_verify_on_session(user) self.mark_password_ok(user)
return Response() return Response()

View File

@ -40,5 +40,5 @@ class TokenCreateApi(AuthMixin, CreateAPIView):
return Response(e.as_data(), status=400) return Response(e.as_data(), status=400)
except errors.NeedMoreInfoError as e: except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200) return Response(e.as_data(), status=200)
except errors.PasswdTooSimple as e: except errors.PasswordTooSimple as e:
return redirect(e.url) return redirect(e.url)

View File

@ -8,7 +8,6 @@ from rest_framework import status
from common.exceptions import JMSException from common.exceptions import JMSException
from .signals import post_auth_failed from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils from users.utils import LoginBlockUtil, MFABlockUtils
from users.models import MFAType
reason_password_failed = 'password_failed' reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed' reason_password_decrypt_failed = 'password_decrypt_failed'
@ -60,22 +59,11 @@ block_mfa_msg = _(
"The account has been locked " "The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)" "(please contact admin to unlock it or try again after {} minutes)"
) )
otp_failed_msg = _( mfa_error_msg = _(
"One-time password invalid, or ntp sync server time, " "{error},"
"You can also try {times_try} times " "You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)" "(The account will be temporarily locked for {block_time} minutes)"
) )
sms_failed_msg = _(
"SMS verify code invalid,"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
mfa_type_failed_msg = _(
"The MFA type({mfa_type}) is not supported, "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
mfa_required_msg = _("MFA required") mfa_required_msg = _("MFA required")
mfa_unset_msg = _("MFA not set, please set it first") mfa_unset_msg = _("MFA not set, please set it first")
otp_unset_msg = _("OTP not set, please set it first") otp_unset_msg = _("OTP not set, please set it first")
@ -151,29 +139,19 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
error = reason_mfa_failed error = reason_mfa_failed
msg: str msg: str
def __init__(self, username, request, ip, mfa_type=MFAType.OTP): def __init__(self, username, request, ip, mfa_type, error):
util = MFABlockUtils(username, ip) super().__init__(username=username, request=request)
util.incr_failed_count()
times_remainder = util.get_remainder_times() util = MFABlockUtils(username, ip)
times_remainder = util.incr_failed_count()
block_time = settings.SECURITY_LOGIN_LIMIT_TIME block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder: if times_remainder:
if mfa_type == MFAType.OTP: self.msg = mfa_error_msg.format(
self.msg = otp_failed_msg.format( error=error, times_try=times_remainder, block_time=block_time
times_try=times_remainder, block_time=block_time )
)
elif mfa_type == MFAType.SMS_CODE:
self.msg = sms_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
else:
self.msg = mfa_type_failed_msg.format(
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
)
else: else:
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
super().__init__(username=username, request=request)
class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError): class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
@ -228,7 +206,7 @@ class MFARequiredError(NeedMoreInfoError):
msg = mfa_required_msg msg = mfa_required_msg
error = 'mfa_required' error = 'mfa_required'
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)): def __init__(self, error='', msg='', mfa_types=()):
super().__init__(error=error, msg=msg) super().__init__(error=error, msg=msg)
self.choices = mfa_types self.choices = mfa_types
@ -305,7 +283,7 @@ class SSOAuthClosed(JMSException):
default_detail = _('SSO auth closed') default_detail = _('SSO auth closed')
class PasswdTooSimple(JMSException): class PasswordTooSimple(JMSException):
default_code = 'passwd_too_simple' default_code = 'passwd_too_simple'
default_detail = _('Your password is too simple, please change it for security') default_detail = _('Your password is too simple, please change it for security')
@ -314,7 +292,7 @@ class PasswdTooSimple(JMSException):
self.url = url self.url = url
class PasswdNeedUpdate(JMSException): class PasswordNeedUpdate(JMSException):
default_code = 'passwd_need_update' default_code = 'passwd_need_update'
default_detail = _('You should to change your password before login') default_detail = _('You should to change your password before login')
@ -357,7 +335,7 @@ class FeiShuNotBound(JMSException):
default_detail = 'FeiShu is not bound' default_detail = 'FeiShu is not bound'
class PasswdInvalid(JMSException): class PasswordInvalid(JMSException):
default_code = 'passwd_invalid' default_code = 'passwd_invalid'
default_detail = _('Your password is invalid') default_detail = _('Your password is invalid')
@ -368,10 +346,6 @@ class NotHaveUpDownLoadPerm(JMSException):
default_detail = _('No upload or download permission') default_detail = _('No upload or download permission')
class NotEnableMFAError(JMSException):
default_detail = mfa_unset_msg
class OTPBindRequiredError(JMSException): class OTPBindRequiredError(JMSException):
default_detail = otp_unset_msg default_detail = otp_unset_msg
@ -380,11 +354,13 @@ class OTPBindRequiredError(JMSException):
self.url = url self.url = url
class OTPCodeRequiredError(AuthFailedError): class MFACodeRequiredError(AuthFailedError):
msg = _("Please enter MFA code") msg = _("Please enter MFA code")
class SMSCodeRequiredError(AuthFailedError): class SMSCodeRequiredError(AuthFailedError):
msg = _("Please enter SMS code") msg = _("Please enter SMS code")
class UserPhoneNotSet(AuthFailedError): class UserPhoneNotSet(AuthFailedError):
msg = _('Phone not set') msg = _('Phone not set')

View File

@ -0,0 +1,5 @@
from .otp import MFAOtp, otp_failed_msg
from .sms import MFASms
from .radius import MFARadius
MFA_BACKENDS = [MFAOtp, MFASms, MFARadius]

View File

@ -0,0 +1,72 @@
import abc
from django.utils.translation import ugettext_lazy as _
class BaseMFA(abc.ABC):
placeholder = _('Please input security code')
def __init__(self, user):
"""
:param user: Authenticated user, Anonymous or None
因为首页登录时可能没法获取到一些状态
"""
self.user = user
def is_authenticated(self):
return self.user and self.user.is_authenticated
@property
@abc.abstractmethod
def name(self):
return ''
@property
@abc.abstractmethod
def display_name(self):
return ''
@staticmethod
def challenge_required():
return False
def send_challenge(self):
pass
@abc.abstractmethod
def check_code(self, code) -> tuple:
return False, 'Error msg'
@abc.abstractmethod
def is_active(self):
return False
@staticmethod
@abc.abstractmethod
def global_enabled():
return False
@abc.abstractmethod
def get_enable_url(self) -> str:
return ''
@abc.abstractmethod
def get_disable_url(self) -> str:
return ''
@abc.abstractmethod
def disable(self):
pass
@abc.abstractmethod
def can_disable(self) -> bool:
return True
@staticmethod
def help_text_of_enable():
return ''
@staticmethod
def help_text_of_disable():
return ''

View File

@ -0,0 +1,51 @@
from django.utils.translation import gettext_lazy as _
from django.shortcuts import reverse
from .base import BaseMFA
otp_failed_msg = _("OTP code invalid, or server time error")
class MFAOtp(BaseMFA):
name = 'otp'
display_name = _('OTP')
def check_code(self, code):
from users.utils import check_otp_code
assert self.is_authenticated()
ok = check_otp_code(self.user.otp_secret_key, code)
msg = '' if ok else otp_failed_msg
return ok, msg
def is_active(self):
if not self.is_authenticated():
return True
return self.user.otp_secret_key
@staticmethod
def global_enabled():
return True
def get_enable_url(self) -> str:
return reverse('authentication:user-otp-enable-start')
def disable(self):
assert self.is_authenticated()
self.user.otp_secret_key = ''
self.user.save(update_fields=['otp_secret_key'])
def can_disable(self) -> bool:
return True
def get_disable_url(self):
return reverse('authentication:user-otp-disable')
@staticmethod
def help_text_of_enable():
return _("Virtual OTP based MFA")
def help_text_of_disable(self):
return ''

View File

@ -0,0 +1,46 @@
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from .base import BaseMFA
from ..backends.radius import RadiusBackend
mfa_failed_msg = _("Radius verify code invalid")
class MFARadius(BaseMFA):
name = 'otp_radius'
display_name = _('Radius MFA')
def check_code(self, code):
assert self.is_authenticated()
backend = RadiusBackend()
username = self.user.username
user = backend.authenticate(
None, username=username, password=code
)
ok = user is not None
msg = '' if ok else mfa_failed_msg
return ok, msg
def is_active(self):
return True
@staticmethod
def global_enabled():
return settings.OTP_IN_RADIUS
def get_enable_url(self) -> str:
return ''
def can_disable(self):
return False
def disable(self):
return ''
@staticmethod
def help_text_of_disable():
return _("Radius global enabled, cannot disable")
def get_disable_url(self) -> str:
return ''

View File

@ -0,0 +1,60 @@
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from .base import BaseMFA
from common.sdk.sms import SendAndVerifySMSUtil
sms_failed_msg = _("SMS verify code invalid")
class MFASms(BaseMFA):
name = 'sms'
display_name = _("SMS")
placeholder = _("SMS verification code")
def __init__(self, user):
super().__init__(user)
phone = user.phone if self.is_authenticated() else ''
self.sms = SendAndVerifySMSUtil(phone)
def check_code(self, code):
assert self.is_authenticated()
ok = self.sms.verify(code)
msg = '' if ok else sms_failed_msg
return ok, msg
def is_active(self):
if not self.is_authenticated():
return True
return self.user.phone
@staticmethod
def challenge_required():
return True
def send_challenge(self):
self.sms.gen_and_send()
@staticmethod
def global_enabled():
return settings.SMS_ENABLED
def get_enable_url(self) -> str:
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
def can_disable(self) -> bool:
return True
def disable(self):
return '/ui/#/users/profile/?activeTab=ProfileUpdate'
@staticmethod
def help_text_of_enable():
return _("Set phone number to enable")
@staticmethod
def help_text_of_disable():
return _("Clear phone number to disable")
def get_disable_url(self) -> str:
return '/ui/#/users/profile/?activeTab=ProfileUpdate'

View File

@ -10,5 +10,5 @@ class MFAMiddleware:
if request.path.find('/auth/login/otp/') > -1: if request.path.find('/auth/login/otp/') > -1:
return response return response
if request.session.get('auth_mfa_required'): if request.session.get('auth_mfa_required'):
return redirect('authentication:login-otp') return redirect('authentication:login-mfa')
return response return response

View File

@ -1,24 +1,26 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
import inspect import inspect
from django.utils.http import urlencode
from functools import partial from functools import partial
import time import time
from typing import Callable
from django.utils.http import urlencode
from django.core.cache import cache from django.core.cache import cache
from django.conf import settings from django.conf import settings
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.contrib import auth from django.contrib import auth
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from rest_framework.request import Request
from django.contrib.auth import ( from django.contrib.auth import (
BACKEND_SESSION_KEY, _get_backends, BACKEND_SESSION_KEY, _get_backends,
PermissionDenied, user_login_failed, _clean_credentials PermissionDenied, user_login_failed, _clean_credentials
) )
from django.shortcuts import reverse, redirect from django.shortcuts import reverse, redirect, get_object_or_404
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
from acls.models import LoginACL from acls.models import LoginACL
from users.models import User, MFAType from users.models import User
from users.utils import LoginBlockUtil, MFABlockUtils from users.utils import LoginBlockUtil, MFABlockUtils
from . import errors from . import errors
from .utils import rsa_decrypt, gen_key_pair from .utils import rsa_decrypt, gen_key_pair
@ -32,8 +34,7 @@ def check_backend_can_auth(username, backend_path, allowed_auth_backends):
if allowed_auth_backends is not None and backend_path not in allowed_auth_backends: if allowed_auth_backends is not None and backend_path not in allowed_auth_backends:
logger.debug('Skip user auth backend: {}, {} not in'.format( logger.debug('Skip user auth backend: {}, {} not in'.format(
username, backend_path, ','.join(allowed_auth_backends) username, backend_path, ','.join(allowed_auth_backends)
) ))
)
return False return False
return True return True
@ -109,17 +110,18 @@ class PasswordEncryptionViewMixin:
def decrypt_passwd(self, raw_passwd): def decrypt_passwd(self, raw_passwd):
# 获取解密密钥,对密码进行解密 # 获取解密密钥,对密码进行解密
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if rsa_private_key is not None: if rsa_private_key is None:
try: return raw_passwd
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e: try:
logger.error(e, exc_info=True) return rsa_decrypt(raw_passwd, rsa_private_key)
logger.error( except Exception as e:
f'Decrypt password failed: password[{raw_passwd}] ' logger.error(e, exc_info=True)
f'rsa_private_key[{rsa_private_key}]' logger.error(
) f'Decrypt password failed: password[{raw_passwd}] '
return None f'rsa_private_key[{rsa_private_key}]'
return raw_passwd )
return None
def get_request_ip(self): def get_request_ip(self):
ip = '' ip = ''
@ -132,7 +134,7 @@ class PasswordEncryptionViewMixin:
# 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用 # 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用
rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY)
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if not all((rsa_private_key, rsa_public_key)): if not all([rsa_private_key, rsa_public_key]):
rsa_private_key, rsa_public_key = gen_key_pair() rsa_private_key, rsa_public_key = gen_key_pair()
rsa_public_key = rsa_public_key.replace('\n', '\\n') rsa_public_key = rsa_public_key.replace('\n', '\\n')
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
@ -144,49 +146,9 @@ class PasswordEncryptionViewMixin:
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class AuthMixin(PasswordEncryptionViewMixin): class CommonMixin(PasswordEncryptionViewMixin):
request = None request: Request
partial_credential_error = None get_request_ip: Callable
key_prefix_captcha = "_LOGIN_INVALID_{}"
def get_user_from_session(self):
if self.request.session.is_empty():
raise errors.SessionEmptyError()
if all((self.request.user,
not self.request.user.is_anonymous,
BACKEND_SESSION_KEY in self.request.session)):
user = self.request.user
user.backend = self.request.session[BACKEND_SESSION_KEY]
return user
user_id = self.request.session.get('user_id')
if not user_id:
user = None
else:
user = get_object_or_none(User, pk=user_id)
if not user:
raise errors.SessionEmptyError()
user.backend = self.request.session.get("auth_backend")
return user
def _check_is_block(self, username, raise_exception=True):
ip = self.get_request_ip()
if LoginBlockUtil(username, ip).is_block():
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockLoginError(username=username, ip=ip)
if raise_exception:
raise errors.BlockLoginError(username=username, ip=ip)
else:
return exception
def check_is_block(self, raise_exception=True):
if hasattr(self.request, 'data'):
username = self.request.data.get("username")
else:
username = self.request.POST.get("username")
self._check_is_block(username, raise_exception)
def raise_credential_error(self, error): def raise_credential_error(self, error):
raise self.partial_credential_error(error=error) raise self.partial_credential_error(error=error)
@ -197,6 +159,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
ip=ip, request=request ip=ip, request=request
) )
def get_user_from_session(self):
if self.request.session.is_empty():
raise errors.SessionEmptyError()
if all([
self.request.user,
not self.request.user.is_anonymous,
BACKEND_SESSION_KEY in self.request.session
]):
user = self.request.user
user.backend = self.request.session[BACKEND_SESSION_KEY]
return user
user_id = self.request.session.get('user_id')
auth_password = self.request.session.get('auth_password')
auth_expired_at = self.request.session.get('auth_password_expired_at')
auth_expired = auth_expired_at < time.time() if auth_expired_at else False
if not user_id or not auth_password or auth_expired:
raise errors.SessionEmptyError()
user = get_object_or_404(User, pk=user_id)
user.backend = self.request.session.get("auth_backend")
return user
def get_auth_data(self, decrypt_passwd=False): def get_auth_data(self, decrypt_passwd=False):
request = self.request request = self.request
if hasattr(request, 'data'): if hasattr(request, 'data'):
@ -214,6 +201,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
password = password + challenge.strip() password = password + challenge.strip()
return username, password, public_key, ip, auto_login return username, password, public_key, ip, auto_login
class AuthPreCheckMixin:
request: Request
get_request_ip: Callable
raise_credential_error: Callable
def _check_is_block(self, username, raise_exception=True):
ip = self.get_request_ip()
is_block = LoginBlockUtil(username, ip).is_block()
if not is_block:
return
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
exception = errors.BlockLoginError(username=username, ip=ip)
if raise_exception:
raise errors.BlockLoginError(username=username, ip=ip)
else:
return exception
def check_is_block(self, raise_exception=True):
if hasattr(self.request, 'data'):
username = self.request.data.get("username")
else:
username = self.request.POST.get("username")
self._check_is_block(username, raise_exception)
def _check_only_allow_exists_user_auth(self, username): def _check_only_allow_exists_user_auth(self, username):
# 仅允许预先存在的用户认证 # 仅允许预先存在的用户认证
if not settings.ONLY_ALLOW_EXIST_USER_AUTH: if not settings.ONLY_ALLOW_EXIST_USER_AUTH:
@ -224,105 +236,92 @@ class AuthMixin(PasswordEncryptionViewMixin):
logger.error(f"Only allow exist user auth, login failed: {username}") logger.error(f"Only allow exist user auth, login failed: {username}")
self.raise_credential_error(errors.reason_user_not_exist) 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)
if not user:
self.raise_credential_error(errors.reason_password_failed)
elif user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
return user
def _check_login_mfa_login_if_need(self, user): class MFAMixin:
request: Request
get_user_from_session: Callable
get_request_ip: Callable
def _check_login_page_mfa_if_need(self, user):
if not settings.SECURITY_MFA_IN_LOGIN_PAGE:
return
request = self.request request = self.request
if hasattr(request, 'data'): data = request.data if hasattr(request, 'data') else request.POST
data = request.data
else:
data = request.POST
code = data.get('code') code = data.get('code')
mfa_type = data.get('mfa_type') mfa_type = data.get('mfa_type', 'otp')
if settings.SECURITY_MFA_IN_LOGIN_PAGE and mfa_type: if not code:
if not code: raise errors.MFACodeRequiredError
if mfa_type == MFAType.OTP and bool(user.otp_secret_key): self._do_check_user_mfa(code, mfa_type, user=user)
raise errors.OTPCodeRequiredError
elif mfa_type == MFAType.SMS_CODE:
raise errors.SMSCodeRequiredError
self.check_user_mfa(code, mfa_type, user=user)
def _check_login_acl(self, user, ip): def check_user_mfa_if_need(self, user):
# ACL 限制用户登录 if self.request.session.get('auth_mfa'):
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip) return
if not is_allowed: if not user.mfa_enabled:
if limit_type == 'ip': return
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
elif limit_type == 'time':
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
def set_login_failed_mark(self): active_mfa_mapper = user.active_mfa_backends_mapper
if not active_mfa_mapper:
url = reverse('authentication:user-otp-enable-start')
raise errors.MFAUnsetError(user, self.request, url)
raise errors.MFARequiredError(mfa_types=tuple(active_mfa_mapper.keys()))
def mark_mfa_ok(self, mfa_type):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_required'] = 0
self.request.session['auth_mfa_type'] = mfa_type
def clean_mfa_mark(self):
keys = ['auth_mfa', 'auth_mfa_time', 'auth_mfa_required', 'auth_mfa_type']
for k in keys:
self.request.session.pop(k, '')
def check_mfa_is_block(self, username, ip, raise_exception=True):
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 _do_check_user_mfa(self, code, mfa_type, user=None):
user = user if user else self.get_user_from_session()
if not user.mfa_enabled:
return
# 监测 MFA 是不是屏蔽了
ip = self.get_request_ip() ip = self.get_request_ip()
cache.set(self.key_prefix_captcha.format(ip), 1, 3600) self.check_mfa_is_block(user.username, ip)
def set_passwd_verify_on_session(self, user: User): ok = False
self.request.session['user_id'] = str(user.id) mfa_backend = user.get_mfa_backend_by_type(mfa_type)
self.request.session['auth_password'] = 1 if mfa_backend:
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS ok, msg = mfa_backend.check_code(code)
else:
msg = _('The MFA type({}) is not supported'.format(mfa_type))
def check_is_need_captcha(self): if ok:
# 最近有登录失败时需要填写验证码 self.mark_mfa_ok(mfa_type)
ip = get_request_ip(self.request) return
need = cache.get(self.key_prefix_captcha.format(ip))
return need
def check_user_auth(self, decrypt_passwd=False): raise errors.MFAFailedError(
self.check_is_block() username=user.username,
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd) request=self.request,
ip=ip, mfa_type=mfa_type,
error=msg
)
self._check_only_allow_exists_user_auth(username) @staticmethod
user = self._check_auth_user_is_valid(username, password, public_key) def get_user_mfa_context(user=None):
# 校验login-acl规则 mfa_backends = User.get_user_mfa_backends(user)
self._check_login_acl(user, ip) return {'mfa_backends': mfa_backends}
self._check_password_require_reset_or_not(user)
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
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
return user
def _check_is_local_user(self, user: User):
if user.source != User.Source.local:
raise self.raise_credential_error(error=errors.only_local_users_are_allowed)
def check_oauth2_auth(self, user: User, auth_backend):
ip = self.get_request_ip()
request = self.request
self._set_partial_credential_error(user.username, ip, request)
if user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
self._check_is_block(user.username)
self._check_login_acl(user, ip)
LoginBlockUtil(user.username, ip).clean_failed_count()
MFABlockUtils(user.username, ip).clean_failed_count()
request.session['auth_password'] = 1
request.session['user_id'] = str(user.id)
request.session['auth_backend'] = auth_backend
return user
class AuthPostCheckMixin:
@classmethod @classmethod
def generate_reset_password_url_with_flash_msg(cls, user, message): def generate_reset_password_url_with_flash_msg(cls, user, message):
reset_passwd_url = reverse('authentication:reset-password') reset_passwd_url = reverse('authentication:reset-password')
@ -344,14 +343,14 @@ class AuthMixin(PasswordEncryptionViewMixin):
if user.is_superuser and password == 'admin': if user.is_superuser and password == 'admin':
message = _('Your password is too simple, please change it for security') message = _('Your password is too simple, please change it for security')
url = cls.generate_reset_password_url_with_flash_msg(user, message=message) url = cls.generate_reset_password_url_with_flash_msg(user, message=message)
raise errors.PasswdTooSimple(url) raise errors.PasswordTooSimple(url)
@classmethod @classmethod
def _check_passwd_need_update(cls, user: User): def _check_passwd_need_update(cls, user: User):
if user.need_update_password: if user.need_update_password:
message = _('You should to change your password before login') message = _('You should to change your password before login')
url = cls.generate_reset_password_url_with_flash_msg(user, message) url = cls.generate_reset_password_url_with_flash_msg(user, message)
raise errors.PasswdNeedUpdate(url) raise errors.PasswordNeedUpdate(url)
@classmethod @classmethod
def _check_password_require_reset_or_not(cls, user: User): def _check_password_require_reset_or_not(cls, user: User):
@ -360,76 +359,20 @@ class AuthMixin(PasswordEncryptionViewMixin):
url = cls.generate_reset_password_url_with_flash_msg(user, message) url = cls.generate_reset_password_url_with_flash_msg(user, message)
raise errors.PasswordRequireResetError(url) raise errors.PasswordRequireResetError(url)
def check_user_auth_if_need(self, decrypt_passwd=False):
request = self.request
if request.session.get('auth_password') and \
request.session.get('user_id'):
user = self.get_user_from_session()
if user:
return user
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
def check_user_mfa_if_need(self, user): class AuthACLMixin:
if self.request.session.get('auth_mfa'): request: Request
get_request_ip: Callable
def _check_login_acl(self, user, ip):
# ACL 限制用户登录
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
if is_allowed:
return return
if settings.OTP_IN_RADIUS: if limit_type == 'ip':
return raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
if not user.mfa_enabled: elif limit_type == 'time':
return raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
unset, url = user.mfa_enabled_but_not_set()
if unset:
raise errors.MFAUnsetError(user, self.request, url)
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = mfa_type
def clean_mfa_mark(self):
self.request.session['auth_mfa'] = ''
self.request.session['auth_mfa_time'] = ''
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = ''
def check_mfa_is_block(self, username, ip, raise_exception=True):
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
if not bool(user.phone) and mfa_type == MFAType.SMS_CODE:
raise errors.UserPhoneNotSet
if not bool(user.otp_secret_key) and mfa_type == MFAType.OTP:
self.set_passwd_verify_on_session(user)
raise errors.OTPBindRequiredError(reverse_lazy('authentication:user-otp-enable-bind'))
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
raise errors.MFAFailedError(
username=user.username,
request=self.request,
ip=ip, mfa_type=mfa_type,
)
def get_ticket(self): def get_ticket(self):
from tickets.models import Ticket from tickets.models import Ticket
@ -480,11 +423,99 @@ class AuthMixin(PasswordEncryptionViewMixin):
self.get_ticket_or_create(confirm_setting) self.get_ticket_or_create(confirm_setting)
self.check_user_login_confirm() self.check_user_login_confirm()
class AuthMixin(CommonMixin, AuthPreCheckMixin, AuthACLMixin, MFAMixin, AuthPostCheckMixin):
request = None
partial_credential_error = None
key_prefix_captcha = "_LOGIN_INVALID_{}"
def _check_auth_user_is_valid(self, username, password, public_key):
user = authenticate(
self.request, username=username,
password=password, public_key=public_key
)
if not user:
self.raise_credential_error(errors.reason_password_failed)
elif user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
return user
def set_login_failed_mark(self):
ip = self.get_request_ip()
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
def check_is_need_captcha(self):
# 最近有登录失败时需要填写验证码
ip = get_request_ip(self.request)
need = cache.get(self.key_prefix_captcha.format(ip))
return need
def check_user_auth(self, decrypt_passwd=False):
# pre check
self.check_is_block()
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
self._check_only_allow_exists_user_auth(username)
# check auth
user = self._check_auth_user_is_valid(username, password, public_key)
# 校验login-acl规则
self._check_login_acl(user, ip)
# post check
self._check_password_require_reset_or_not(user)
self._check_passwd_is_too_simple(user, password)
self._check_passwd_need_update(user)
# 校验login-mfa, 如果登录页面上显示 mfa 的话
self._check_login_page_mfa_if_need(user)
# 标记密码验证成功
self.mark_password_ok(user=user, auto_login=auto_login)
LoginBlockUtil(user.username, ip).clean_failed_count()
return user
def mark_password_ok(self, user, auto_login=False):
request = self.request
request.session['auth_password'] = 1
request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
request.session['user_id'] = str(user.id)
request.session['auto_login'] = auto_login
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
def check_oauth2_auth(self, user: User, auth_backend):
ip = self.get_request_ip()
request = self.request
self._set_partial_credential_error(user.username, ip, request)
if user.is_expired:
self.raise_credential_error(errors.reason_user_expired)
elif not user.is_active:
self.raise_credential_error(errors.reason_user_inactive)
self._check_is_block(user.username)
self._check_login_acl(user, ip)
LoginBlockUtil(user.username, ip).clean_failed_count()
MFABlockUtils(user.username, ip).clean_failed_count()
self.mark_password_ok(user, False)
return user
def check_user_auth_if_need(self, decrypt_passwd=False):
request = self.request
if not request.session.get('auth_password'):
return self.check_user_auth(decrypt_passwd=decrypt_passwd)
return self.get_user_from_session()
def clear_auth_mark(self): def clear_auth_mark(self):
self.request.session['auth_password'] = '' keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id']
self.request.session['auth_user_id'] = '' for k in keys:
self.request.session['auth_confirm'] = '' self.request.session.pop(k, '')
self.request.session['auth_ticket_id'] = ''
def send_auth_signal(self, success=True, user=None, username='', reason=''): def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success: if success:
@ -503,31 +534,3 @@ class AuthMixin(PasswordEncryptionViewMixin):
if args: if args:
guard_url = "%s?%s" % (guard_url, args) guard_url = "%s?%s" % (guard_url, args)
return redirect(guard_url) 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

View File

@ -78,6 +78,7 @@ class BearerTokenSerializer(serializers.Serializer):
class MFASelectTypeSerializer(serializers.Serializer): class MFASelectTypeSerializer(serializers.Serializer):
type = serializers.CharField() type = serializers.CharField()
username = serializers.CharField(required=False, allow_blank=True, allow_null=True)
class MFAChallengeSerializer(serializers.Serializer): class MFAChallengeSerializer(serializers.Serializer):

View File

@ -13,11 +13,11 @@ from .signals import post_auth_success, post_auth_failed
@receiver(user_logged_in) @receiver(user_logged_in)
def on_user_auth_login_success(sender, user, request, **kwargs): def on_user_auth_login_success(sender, user, request, **kwargs):
# 开启了 MFA且没有校验过 # 开启了 MFA且没有校验过, 可以全局校验, middleware 中可以全局管理 oidc 等第三方认证的 MFA
if user.mfa_enabled and not request.session.get('auth_mfa'):
if user.mfa_enabled and not settings.OTP_IN_RADIUS and not request.session.get('auth_mfa'):
request.session['auth_mfa_required'] = 1 request.session['auth_mfa_required'] = 1
# 单点登录,超过了自动退出
if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED: if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED:
user_id = 'single_machine_login_' + str(user.id) user_id = 'single_machine_login_' + str(user.id)
session_key = cache.get(user_id) session_key = cache.get(user_id)

View File

@ -160,7 +160,7 @@
{% bootstrap_field form.challenge show_label=False %} {% bootstrap_field form.challenge show_label=False %}
{% elif form.mfa_type %} {% elif form.mfa_type %}
<div class="form-group" style="display: flex"> <div class="form-group" style="display: flex">
{% include '_mfa_otp_login.html' %} {% include '_mfa_login_field.html' %}
</div> </div>
{% elif form.captcha %} {% elif form.captcha %}
<div class="captch-field"> <div class="captch-field">
@ -208,6 +208,7 @@
</div> </div>
</div> </div>
</body> </body>
{% include '_foot_js.html' %}
<script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script> <script type="text/javascript" src="/static/js/plugins/jsencrypt/jsencrypt.min.js"></script>
<script> <script>
function encryptLoginPassword(password, rsaPublicKey) { function encryptLoginPassword(password, rsaPublicKey) {

View File

@ -13,19 +13,18 @@
<p class="red-fonts">{{ form.code.errors.as_text }}</p> <p class="red-fonts">{{ form.code.errors.as_text }}</p>
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
{% include '_mfa_otp_login.html' %} {% include '_mfa_login_field.html' %}
</div> </div>
<button id='submit_button' type="submit" <button id='submit_button' type="submit" class="btn btn-primary block full-width m-b">
class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button> {% trans 'Next' %}
</button>
<div> <div>
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small> <small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
</div> </div>
</form> </form>
<style type="text/css"> <style>
.mfa-div { .mfa-div {
margin-top: 15px; margin-top: 15px;
} }
</style> </style>
<script>
</script>
{% endblock %} {% endblock %}

View File

@ -25,10 +25,11 @@ urlpatterns = [
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'), path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'), path('mfa/challenge/', api.MFAChallengeVerifyApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASendCodeApi.as_view(), name='mfa-select'),
path('mfa/send-code/', api.MFASendCodeApi.as_view(), name='mfa-send-codej'),
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
] ]

View File

@ -12,7 +12,7 @@ app_name = 'authentication'
urlpatterns = [ urlpatterns = [
# login # login
path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'), path('login/', non_atomic_requests(views.UserLoginView.as_view()), name='login'),
path('login/otp/', views.UserLoginOtpView.as_view(), name='login-otp'), path('login/mfa/', views.UserLoginMFAView.as_view(), name='login-mfa'),
path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'), path('login/wait-confirm/', views.UserLoginWaitConfirmView.as_view(), name='login-wait-confirm'),
path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'), path('login/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
path('logout/', views.UserLogoutView.as_view(), name='logout'), path('logout/', views.UserLogoutView.as_view(), name='logout'),
@ -42,14 +42,15 @@ urlpatterns = [
# Profile # Profile
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'), path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
path('profile/mfa/', users_view.MFASettingView.as_view(), name='user-mfa-setting'),
# OTP Setting
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'), path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
path('profile/otp/enable/install-app/', users_view.UserOtpEnableInstallAppView.as_view(), path('profile/otp/enable/install-app/', users_view.UserOtpEnableInstallAppView.as_view(),
name='user-otp-enable-install-app'), name='user-otp-enable-install-app'),
path('profile/otp/enable/bind/', users_view.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'), path('profile/otp/enable/bind/', users_view.UserOtpEnableBindView.as_view(), name='user-otp-enable-bind'),
path('profile/otp/disable/authentication/', users_view.UserDisableMFAView.as_view(), path('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
name='user-otp-disable-authentication'), name='user-otp-disable'),
path('profile/otp/update/', users_view.UserOtpUpdateView.as_view(), name='user-otp-update'),
path('profile/otp/settings-success/', users_view.UserOtpSettingsSuccessView.as_view(), name='user-otp-settings-success'),
path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'), path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'),
# openid # openid

View File

@ -122,16 +122,16 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.set_test_cookie() self.request.session.set_test_cookie()
return self.render_to_response(context) return self.render_to_response(context)
except ( except (
errors.PasswdTooSimple, errors.PasswordTooSimple,
errors.PasswordRequireResetError, errors.PasswordRequireResetError,
errors.PasswdNeedUpdate, errors.PasswordNeedUpdate,
errors.OTPBindRequiredError errors.OTPBindRequiredError
) as e: ) as e:
return redirect(e.url) return redirect(e.url)
except ( except (
errors.MFAFailedError, errors.MFAFailedError,
errors.BlockMFAError, errors.BlockMFAError,
errors.OTPCodeRequiredError, errors.MFACodeRequiredError,
errors.SMSCodeRequiredError, errors.SMSCodeRequiredError,
errors.UserPhoneNotSet errors.UserPhoneNotSet
) as e: ) as e:
@ -199,7 +199,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
'demo_mode': os.environ.get("DEMO_MODE"), 'demo_mode': os.environ.get("DEMO_MODE"),
'auth_methods': self.get_support_auth_methods(), 'auth_methods': self.get_support_auth_methods(),
'forgot_password_url': self.get_forgot_password_url(), 'forgot_password_url': self.get_forgot_password_url(),
'methods': self.get_user_mfa_methods(), **self.get_user_mfa_context(self.request.user)
} }
kwargs.update(context) kwargs.update(context)
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
@ -208,7 +208,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
class UserLoginGuardView(mixins.AuthMixin, RedirectView): class UserLoginGuardView(mixins.AuthMixin, RedirectView):
redirect_field_name = 'next' redirect_field_name = 'next'
login_url = reverse_lazy('authentication:login') login_url = reverse_lazy('authentication:login')
login_otp_url = reverse_lazy('authentication:login-otp') login_mfa_url = reverse_lazy('authentication:login-mfa')
login_confirm_url = reverse_lazy('authentication:login-wait-confirm') login_confirm_url = reverse_lazy('authentication:login-wait-confirm')
def format_redirect_url(self, url): def format_redirect_url(self, url):
@ -229,15 +229,16 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
user = self.check_user_auth_if_need() user = self.check_user_auth_if_need()
self.check_user_mfa_if_need(user) self.check_user_mfa_if_need(user)
self.check_user_login_confirm_if_need(user) self.check_user_login_confirm_if_need(user)
except (errors.CredentialError, errors.SessionEmptyError): except (errors.CredentialError, errors.SessionEmptyError) as e:
print("Error: ", e)
return self.format_redirect_url(self.login_url) return self.format_redirect_url(self.login_url)
except errors.MFARequiredError: except errors.MFARequiredError:
return self.format_redirect_url(self.login_otp_url) return self.format_redirect_url(self.login_mfa_url)
except errors.LoginConfirmBaseError: except errors.LoginConfirmBaseError:
return self.format_redirect_url(self.login_confirm_url) return self.format_redirect_url(self.login_confirm_url)
except errors.MFAUnsetError as e: except errors.MFAUnsetError as e:
return e.url return e.url
except errors.PasswdTooSimple as e: except errors.PasswordTooSimple as e:
return e.url return e.url
else: else:
self.login_it(user) self.login_it(user)

View File

@ -3,32 +3,39 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from django.utils.translation import gettext_lazy as _
from django.conf import settings from common.utils import get_logger
from .. import forms, errors, mixins from .. import forms, errors, mixins
from .utils import redirect_to_guard_view from .utils import redirect_to_guard_view
from common.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
__all__ = ['UserLoginOtpView'] __all__ = ['UserLoginMFAView']
class UserLoginOtpView(mixins.AuthMixin, FormView): class UserLoginMFAView(mixins.AuthMixin, FormView):
template_name = 'authentication/login_otp.html' template_name = 'authentication/login_mfa.html'
form_class = forms.UserCheckOtpCodeForm form_class = forms.UserCheckOtpCodeForm
redirect_field_name = 'next' redirect_field_name = 'next'
def get(self, *args, **kwargs):
try:
self.get_user_from_session()
except errors.SessionEmptyError:
return redirect_to_guard_view()
return super().get(*args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
code = form.cleaned_data.get('code') code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type') mfa_type = form.cleaned_data.get('mfa_type')
try: try:
self.check_user_mfa(code, mfa_type) self._do_check_user_mfa(code, mfa_type)
return redirect_to_guard_view() return redirect_to_guard_view()
except (errors.MFAFailedError, errors.BlockMFAError) as e: except (errors.MFAFailedError, errors.BlockMFAError) as e:
form.add_error('code', e.msg) form.add_error('code', e.msg)
return super().form_invalid(form) return super().form_invalid(form)
except errors.SessionEmptyError:
return redirect_to_guard_view()
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
import traceback import traceback
@ -37,6 +44,7 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
user = self.get_user_from_session() user = self.get_user_from_session()
methods = self.get_user_mfa_methods(user) mfa_context = self.get_user_mfa_context(user)
kwargs.update({'methods': methods}) kwargs.update(mfa_context)
return kwargs return kwargs

View File

@ -1,65 +1,2 @@
from collections import OrderedDict from .endpoint import SMS, BACKENDS
import importlib from .utils import SendAndVerifySMSUtil
from django.utils.translation import gettext_lazy as _
from django.db.models import TextChoices
from django.conf import settings
from common.utils import get_logger
from common.exceptions import JMSException
logger = get_logger(__file__)
class BACKENDS(TextChoices):
ALIBABA = 'alibaba', _('Alibaba cloud')
TENCENT = 'tencent', _('Tencent cloud')
class BaseSMSClient:
"""
短信终端的基类
"""
SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str
@classmethod
def new_from_settings(cls):
raise NotImplementedError
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
raise NotImplementedError
class SMS:
client: BaseSMSClient
def __init__(self, backend=None):
backend = backend or settings.SMS_BACKEND
if backend not in BACKENDS:
raise JMSException(
code='sms_provider_not_support',
detail=_('SMS provider not support: {}').format(backend)
)
m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
self.client = m.client.new_from_settings()
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
return self.client.send_sms(
phone_numbers=phone_numbers,
sign_name=sign_name,
template_code=template_code,
template_param=template_param,
**kwargs
)
def send_verify_code(self, phone_number, code):
sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME')
template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE')
if not (sign_name and template_code):
raise JMSException(
code='verify_code_sign_tmpl_invalid',
detail=_('SMS verification code signature or template invalid')
)
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))

View File

@ -9,7 +9,7 @@ from Tea.exceptions import TeaException
from common.utils import get_logger from common.utils import get_logger
from common.exceptions import JMSException from common.exceptions import JMSException
from . import BaseSMSClient from .base import BaseSMSClient
logger = get_logger(__file__) logger = get_logger(__file__)

View File

@ -0,0 +1,20 @@
from common.utils import get_logger
logger = get_logger(__file__)
class BaseSMSClient:
"""
短信终端的基类
"""
SIGN_AND_TMPL_SETTING_FIELD_PREFIX: str
@classmethod
def new_from_settings(cls):
raise NotImplementedError
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
raise NotImplementedError

View File

@ -0,0 +1,51 @@
from collections import OrderedDict
import importlib
from django.utils.translation import gettext_lazy as _
from django.db.models import TextChoices
from django.conf import settings
from common.utils import get_logger
from common.exceptions import JMSException
from .base import BaseSMSClient
logger = get_logger(__name__)
class BACKENDS(TextChoices):
ALIBABA = 'alibaba', _('Alibaba cloud')
TENCENT = 'tencent', _('Tencent cloud')
class SMS:
client: BaseSMSClient
def __init__(self, backend=None):
backend = backend or settings.SMS_BACKEND
if backend not in BACKENDS:
raise JMSException(
code='sms_provider_not_support',
detail=_('SMS provider not support: {}').format(backend)
)
m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
self.client = m.client.new_from_settings()
def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
return self.client.send_sms(
phone_numbers=phone_numbers,
sign_name=sign_name,
template_code=template_code,
template_param=template_param,
**kwargs
)
def send_verify_code(self, phone_number, code):
sign_name = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_SIGN_NAME')
template_code = getattr(settings, f'{self.client.SIGN_AND_TMPL_SETTING_FIELD_PREFIX}_VERIFY_TEMPLATE_CODE')
if not (sign_name and template_code):
raise JMSException(
code='verify_code_sign_tmpl_invalid',
detail=_('SMS verification code signature or template invalid')
)
return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))

View File

@ -10,7 +10,8 @@ from tencentcloud.sms.v20210111 import sms_client, models
# 导入可选配置类 # 导入可选配置类
from tencentcloud.common.profile.client_profile import ClientProfile from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile from tencentcloud.common.profile.http_profile import HttpProfile
from . import BaseSMSClient
from .base import BaseSMSClient
logger = get_logger(__file__) logger = get_logger(__file__)

View File

@ -3,7 +3,7 @@ import random
from django.core.cache import cache from django.core.cache import cache
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from common.sdk.sms import SMS from .endpoint import SMS
from common.utils import get_logger from common.utils import get_logger
from common.exceptions import JMSException from common.exceptions import JMSException
@ -28,32 +28,24 @@ class CodeSendTooFrequently(JMSException):
super().__init__(detail=self.default_detail.format(ttl)) super().__init__(detail=self.default_detail.format(ttl))
class VerifyCodeUtil: class SendAndVerifySMSUtil:
KEY_TMPL = 'auth-verify_code-{}' KEY_TMPL = 'auth-verify-code-{}'
TIMEOUT = 60 TIMEOUT = 60
def __init__(self, account, key_suffix=None, timeout=None): def __init__(self, phone, key_suffix=None, timeout=None):
self.account = account self.phone = phone
self.key_suffix = key_suffix
self.code = '' self.code = ''
self.timeout = timeout or self.TIMEOUT
self.key_suffix = key_suffix or str(phone)
self.key = self.KEY_TMPL.format(key_suffix)
if key_suffix is not None: def gen_and_send(self):
self.key = self.KEY_TMPL.format(key_suffix)
else:
self.key = self.KEY_TMPL.format(account)
self.timeout = self.TIMEOUT if timeout is None else timeout
def touch(self):
""" """
生成保存发送 生成保存发送
""" """
ttl = self.ttl()
if ttl > 0:
raise CodeSendTooFrequently(ttl)
try: try:
self.generate() code = self.generate()
self.save() self.send(code)
self.send()
except JMSException: except JMSException:
self.clear() self.clear()
raise raise
@ -66,19 +58,18 @@ class VerifyCodeUtil:
def clear(self): def clear(self):
cache.delete(self.key) cache.delete(self.key)
def save(self): def send(self, code):
cache.set(self.key, self.code, self.timeout)
def send(self):
""" """
发送信息的方法如果有错误直接抛出 api 异常 发送信息的方法如果有错误直接抛出 api 异常
""" """
account = self.account ttl = self.ttl()
code = self.code if ttl > 0:
logger.error('Send sms too frequently, delay {}'.format(ttl))
raise CodeSendTooFrequently(ttl)
sms = SMS() sms = SMS()
sms.send_verify_code(account, code) sms.send_verify_code(self.phone, code)
logger.info(f'Send sms verify code: account={account} code={code}') cache.set(self.key, self.code, self.timeout)
logger.info(f'Send sms verify code to {self.phone}: {code}')
def verify(self, code): def verify(self, code):
right = cache.get(self.key) right = cache.get(self.key)

View File

@ -106,7 +106,7 @@ TASK_LOG_KEEP_DAYS = CONFIG.TASK_LOG_KEEP_DAYS
ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL ORG_CHANGE_TO_URL = CONFIG.ORG_CHANGE_TO_URL
WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD WINDOWS_SKIP_ALL_MANUAL_PASSWORD = CONFIG.WINDOWS_SKIP_ALL_MANUAL_PASSWORD
AUTH_EXPIRED_SECONDS = 60 * 5 AUTH_EXPIRED_SECONDS = 60 * 10
CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = CONFIG.CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3cb74767fc92b67608deb32d27bf945b7fd4ad46fc02f0cc5ef4cf4a42ebcd10 oid sha256:925c5a219a4ee6835ad59e3b8e9f7ea5074ee3df6527c0f73ef1a50eaedaf59c
size 91465 size 91777

View File

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: JumpServer 0.3.3\n" "Project-Id-Version: JumpServer 0.3.3\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 15:08+0800\n" "POT-Creation-Date: 2021-11-10 10:53+0800\n"
"PO-Revision-Date: 2021-05-20 10:54+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n"
"Last-Translator: ibuler <ibuler@qq.com>\n" "Last-Translator: ibuler <ibuler@qq.com>\n"
"Language-Team: JumpServer team<ibuler@qq.com>\n" "Language-Team: JumpServer team<ibuler@qq.com>\n"
@ -25,7 +25,7 @@ msgstr ""
#: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29 #: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29
#: settings/serializers/sms.py:6 terminal/models/storage.py:23 #: settings/serializers/sms.py:6 terminal/models/storage.py:23
#: terminal/models/task.py:16 terminal/models/terminal.py:100 #: terminal/models/task.py:16 terminal/models/terminal.py:100
#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:597 #: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:541
#: users/templates/users/_select_user_modal.html:13 #: users/templates/users/_select_user_modal.html:13
#: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:37
#: users/templates/users/user_asset_permission.html:154 #: users/templates/users/user_asset_permission.html:154
@ -60,7 +60,7 @@ msgstr "激活中"
#: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34 #: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34
#: terminal/models/storage.py:26 terminal/models/terminal.py:114 #: terminal/models/storage.py:26 terminal/models/terminal.py:114
#: tickets/models/ticket.py:71 users/models/group.py:16 #: tickets/models/ticket.py:71 users/models/group.py:16
#: users/models/user.py:630 xpack/plugins/change_auth_plan/models/base.py:41 #: users/models/user.py:574 xpack/plugins/change_auth_plan/models/base.py:41
#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113 #: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113
#: xpack/plugins/gathered_user/models.py:26 #: xpack/plugins/gathered_user/models.py:26
msgid "Comment" msgid "Comment"
@ -86,8 +86,8 @@ msgstr "登录复核"
#: templates/index.html:78 terminal/backends/command/models.py:18 #: templates/index.html:78 terminal/backends/command/models.py:18
#: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38
#: terminal/notifications.py:90 terminal/notifications.py:138 #: terminal/notifications.py:90 terminal/notifications.py:138
#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:173 #: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:169
#: users/models/user.py:801 users/models/user.py:827 #: users/models/user.py:745 users/models/user.py:771
#: users/serializers/group.py:19 #: users/serializers/group.py:19
#: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:38
#: users/templates/users/user_asset_permission.html:64 #: users/templates/users/user_asset_permission.html:64
@ -162,7 +162,7 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. "
#: assets/models/base.py:176 assets/models/gathered_user.py:15 #: assets/models/base.py:176 assets/models/gathered_user.py:15
#: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 #: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17
#: authentication/templates/authentication/_msg_different_city.html:9 #: authentication/templates/authentication/_msg_different_city.html:9
#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:595 #: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:539
#: users/templates/users/_msg_user_created.html:12 #: users/templates/users/_msg_user_created.html:12
#: users/templates/users/_select_user_modal.html:14 #: users/templates/users/_select_user_modal.html:14
#: xpack/plugins/change_auth_plan/models/asset.py:35 #: xpack/plugins/change_auth_plan/models/asset.py:35
@ -323,7 +323,7 @@ msgid "Attrs"
msgstr "" msgstr ""
#: applications/models/application.py:183 #: applications/models/application.py:183
#: perms/models/application_permission.py:27 users/models/user.py:174 #: perms/models/application_permission.py:27 users/models/user.py:170
msgid "Application" msgid "Application"
msgstr "应用程序" msgstr "应用程序"
@ -402,7 +402,7 @@ msgstr "目标URL"
#: authentication/templates/authentication/login.html:151 #: authentication/templates/authentication/login.html:151
#: settings/serializers/auth/ldap.py:44 users/forms/profile.py:21 #: settings/serializers/auth/ldap.py:44 users/forms/profile.py:21
#: users/templates/users/_msg_user_created.html:13 #: users/templates/users/_msg_user_created.html:13
#: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_otp_check_password.html:15
#: users/templates/users/user_password_update.html:43 #: users/templates/users/user_password_update.html:43
#: users/templates/users/user_password_verify.html:18 #: users/templates/users/user_password_verify.html:18
#: xpack/plugins/change_auth_plan/models/base.py:39 #: xpack/plugins/change_auth_plan/models/base.py:39
@ -553,7 +553,7 @@ msgstr "标签管理"
#: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26
#: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: assets/models/cmd_filter.py:67 assets/models/group.py:21
#: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25
#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:638 #: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:582
#: users/serializers/group.py:33 #: users/serializers/group.py:33
#: xpack/plugins/change_auth_plan/models/base.py:45 #: xpack/plugins/change_auth_plan/models/base.py:45
#: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30 #: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30
@ -566,7 +566,7 @@ msgstr "创建者"
#: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50
#: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26
#: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18 #: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18
#: users/models/user.py:828 xpack/plugins/cloud/models.py:122 #: users/models/user.py:772 xpack/plugins/cloud/models.py:122
msgid "Date created" msgid "Date created"
msgstr "创建日期" msgstr "创建日期"
@ -621,7 +621,7 @@ msgstr "带宽"
msgid "Contact" msgid "Contact"
msgstr "联系人" msgstr "联系人"
#: assets/models/cluster.py:22 users/models/user.py:616 #: assets/models/cluster.py:22 users/models/user.py:560
msgid "Phone" msgid "Phone"
msgstr "手机" msgstr "手机"
@ -647,7 +647,7 @@ msgid "Default"
msgstr "默认" msgstr "默认"
#: assets/models/cluster.py:36 assets/models/label.py:14 #: assets/models/cluster.py:36 assets/models/label.py:14
#: users/models/user.py:813 #: users/models/user.py:757
msgid "System" msgid "System"
msgstr "系统" msgstr "系统"
@ -1219,8 +1219,8 @@ msgstr "用户代理"
#: audits/models.py:110 #: audits/models.py:110
#: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/_mfa_confirm_modal.html:14
#: authentication/templates/authentication/login_otp.html:6 #: authentication/templates/authentication/login_mfa.html:6
#: users/forms/profile.py:64 users/models/user.py:619 #: users/forms/profile.py:64 users/models/user.py:563
#: users/serializers/profile.py:102 #: users/serializers/profile.py:102
msgid "MFA" msgid "MFA"
msgstr "多因子认证" msgstr "多因子认证"
@ -1299,12 +1299,12 @@ msgid "Auth Token"
msgstr "认证令牌" msgstr "认证令牌"
#: audits/signals_handler.py:68 authentication/views/login.py:169 #: audits/signals_handler.py:68 authentication/views/login.py:169
#: notifications/backends/__init__.py:11 users/models/user.py:652 #: notifications/backends/__init__.py:11 users/models/user.py:596
msgid "WeCom" msgid "WeCom"
msgstr "企业微信" msgstr "企业微信"
#: audits/signals_handler.py:69 authentication/views/login.py:175 #: audits/signals_handler.py:69 authentication/views/login.py:175
#: notifications/backends/__init__.py:12 users/models/user.py:653 #: notifications/backends/__init__.py:12 users/models/user.py:597
msgid "DingTalk" msgid "DingTalk"
msgstr "钉钉" msgstr "钉钉"
@ -1495,9 +1495,11 @@ msgstr "{ApplicationPermission} 移除 {SystemUser}"
msgid "Invalid token" msgid "Invalid token"
msgstr "无效的令牌" msgstr "无效的令牌"
#: authentication/api/mfa.py:81 #: authentication/api/mfa.py:102
#, fuzzy
#| msgid "Code is invalid, "
msgid "Code is invalid" msgid "Code is invalid"
msgstr "Code无效" msgstr "验证码无效"
#: authentication/backends/api.py:67 #: authentication/backends/api.py:67
msgid "Invalid signature header. No credentials provided." msgid "Invalid signature header. No credentials provided."
@ -1550,59 +1552,59 @@ msgstr ""
msgid "Invalid token or cache refreshed." msgid "Invalid token or cache refreshed."
msgstr "" msgstr ""
#: authentication/errors.py:27 #: authentication/errors.py:26
msgid "Username/password check failed" msgid "Username/password check failed"
msgstr "用户名/密码 校验失败" msgstr "用户名/密码 校验失败"
#: authentication/errors.py:28 #: authentication/errors.py:27
msgid "Password decrypt failed" msgid "Password decrypt failed"
msgstr "密码解密失败" msgstr "密码解密失败"
#: authentication/errors.py:29 #: authentication/errors.py:28
msgid "MFA failed" msgid "MFA failed"
msgstr "多因子认证失败" msgstr "多因子认证失败"
#: authentication/errors.py:30 #: authentication/errors.py:29
msgid "MFA unset" msgid "MFA unset"
msgstr "多因子认证没有设定" msgstr "多因子认证没有设定"
#: authentication/errors.py:31 #: authentication/errors.py:30
msgid "Username does not exist" msgid "Username does not exist"
msgstr "用户名不存在" msgstr "用户名不存在"
#: authentication/errors.py:32 #: authentication/errors.py:31
msgid "Password expired" msgid "Password expired"
msgstr "密码已过期" msgstr "密码已过期"
#: authentication/errors.py:33 #: authentication/errors.py:32
msgid "Disabled or expired" msgid "Disabled or expired"
msgstr "禁用或失效" msgstr "禁用或失效"
#: authentication/errors.py:34 #: authentication/errors.py:33
msgid "This account is inactive." msgid "This account is inactive."
msgstr "此账户已禁用" msgstr "此账户已禁用"
#: authentication/errors.py:35 #: authentication/errors.py:34
msgid "This account is expired" msgid "This account is expired"
msgstr "此账户已过期" msgstr "此账户已过期"
#: authentication/errors.py:36 #: authentication/errors.py:35
msgid "Auth backend not match" msgid "Auth backend not match"
msgstr "没有匹配到认证后端" msgstr "没有匹配到认证后端"
#: authentication/errors.py:37 #: authentication/errors.py:36
msgid "ACL is not allowed" msgid "ACL is not allowed"
msgstr "ACL 不被允许" msgstr "ACL 不被允许"
#: authentication/errors.py:38 #: authentication/errors.py:37
msgid "Only local users are allowed" msgid "Only local users are allowed"
msgstr "仅允许本地用户" msgstr "仅允许本地用户"
#: authentication/errors.py:48 #: authentication/errors.py:47
msgid "No session found, check your cookie" msgid "No session found, check your cookie"
msgstr "会话已变更,刷新页面" msgstr "会话已变更,刷新页面"
#: authentication/errors.py:50 #: authentication/errors.py:49
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"The username or password you entered is incorrect, please enter it again. " "The username or password you entered is incorrect, please enter it again. "
@ -1612,105 +1614,85 @@ msgstr ""
"您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将"
"被临时 锁定 {block_time} 分钟)" "被临时 锁定 {block_time} 分钟)"
#: authentication/errors.py:56 authentication/errors.py:60 #: authentication/errors.py:55 authentication/errors.py:59
msgid "" msgid ""
"The account has been locked (please contact admin to unlock it or try again " "The account has been locked (please contact admin to unlock it or try again "
"after {} minutes)" "after {} minutes)"
msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)"
#: authentication/errors.py:64 #: authentication/errors.py:63
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"One-time password invalid, or ntp sync server time, You can also try " "{error},You can also try {times_try} times (The account will be temporarily "
"{times_try} times (The account will be temporarily locked for {block_time} " "locked for {block_time} minutes)"
"minutes)"
msgstr "" msgstr ""
"虚拟MFA 不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" "{error},您还可以尝试 {times_try} 次(账号将被临时锁定 {block_time} 分钟)"
"临时 锁定 {block_time} 分钟)"
#: authentication/errors.py:69 #: authentication/errors.py:67
#, python-brace-format
msgid ""
"SMS verify code invalid,You can also try {times_try} times (The account will "
"be temporarily locked for {block_time} minutes)"
msgstr ""
"短信验证码不正确。 您还可以尝试 {times_try} 次(账号将被临时 锁定 "
"{block_time} 分钟)"
#: authentication/errors.py:74
#, python-brace-format
msgid ""
"The MFA type({mfa_type}) is not supported, You can also try {times_try} "
"times (The account will be temporarily locked for {block_time} minutes)"
msgstr ""
"该({mfa_type}) MFA 类型不支持, 您还可以尝试 {times_try} 次(账号将被临时 锁"
"定 {block_time} 分钟)"
#: authentication/errors.py:79
msgid "MFA required" msgid "MFA required"
msgstr "需要多因子认证" msgstr "需要多因子认证"
#: authentication/errors.py:80 #: authentication/errors.py:68
msgid "MFA not set, please set it first" msgid "MFA not set, please set it first"
msgstr "多因子认证没有设置,请先完成设置" msgstr "多因子认证没有设置,请先完成设置"
#: authentication/errors.py:81 #: authentication/errors.py:69
msgid "OTP not set, please set it first" msgid "OTP not set, please set it first"
msgstr "OTP认证没有设置请先完成设置" msgstr "OTP认证没有设置请先完成设置"
#: authentication/errors.py:82 #: authentication/errors.py:70
msgid "Login confirm required" msgid "Login confirm required"
msgstr "需要登录复核" msgstr "需要登录复核"
#: authentication/errors.py:83 #: authentication/errors.py:71
msgid "Wait login confirm ticket for accept" msgid "Wait login confirm ticket for accept"
msgstr "等待登录复核处理" msgstr "等待登录复核处理"
#: authentication/errors.py:84 #: authentication/errors.py:72
msgid "Login confirm ticket was {}" msgid "Login confirm ticket was {}"
msgstr "登录复核 {}" msgstr "登录复核 {}"
#: authentication/errors.py:265 #: authentication/errors.py:243
msgid "IP is not allowed" msgid "IP is not allowed"
msgstr "来源 IP 不被允许登录" msgstr "来源 IP 不被允许登录"
#: authentication/errors.py:272 #: authentication/errors.py:250
msgid "Time Period is not allowed" msgid "Time Period is not allowed"
msgstr "该 时间段 不被允许登录" msgstr "该 时间段 不被允许登录"
#: authentication/errors.py:305 #: authentication/errors.py:283
msgid "SSO auth closed" msgid "SSO auth closed"
msgstr "SSO 认证关闭了" msgstr "SSO 认证关闭了"
#: authentication/errors.py:310 authentication/mixins.py:345 #: authentication/errors.py:288 authentication/mixins.py:344
msgid "Your password is too simple, please change it for security" msgid "Your password is too simple, please change it for security"
msgstr "你的密码过于简单,为了安全,请修改" msgstr "你的密码过于简单,为了安全,请修改"
#: authentication/errors.py:319 authentication/mixins.py:352 #: authentication/errors.py:297 authentication/mixins.py:351
msgid "You should to change your password before login" msgid "You should to change your password before login"
msgstr "登录完成前,请先修改密码" msgstr "登录完成前,请先修改密码"
#: authentication/errors.py:328 authentication/mixins.py:359 #: authentication/errors.py:306 authentication/mixins.py:358
msgid "Your password has expired, please reset before logging in" msgid "Your password has expired, please reset before logging in"
msgstr "您的密码已过期,先修改再登录" msgstr "您的密码已过期,先修改再登录"
#: authentication/errors.py:362 #: authentication/errors.py:340
msgid "Your password is invalid" msgid "Your password is invalid"
msgstr "您的密码无效" msgstr "您的密码无效"
#: authentication/errors.py:368 #: authentication/errors.py:346
msgid "No upload or download permission" msgid "No upload or download permission"
msgstr "没有上传下载权限" msgstr "没有上传下载权限"
#: authentication/errors.py:384 templates/_mfa_otp_login.html:37 #: authentication/errors.py:358
msgid "Please enter MFA code" msgid "Please enter MFA code"
msgstr "请输入6位动态安全码" msgstr "请输入6位动态安全码"
#: authentication/errors.py:387 templates/_mfa_otp_login.html:38 #: authentication/errors.py:362
msgid "Please enter SMS code" msgid "Please enter SMS code"
msgstr "请输入短信验证码" msgstr "请输入短信验证码"
#: authentication/errors.py:390 users/exceptions.py:15 #: authentication/errors.py:366 users/exceptions.py:15
msgid "Phone not set" msgid "Phone not set"
msgstr "手机号没有设置" msgstr "手机号没有设置"
@ -1734,14 +1716,62 @@ msgstr "多因子认证验证码"
msgid "Dynamic code" msgid "Dynamic code"
msgstr "动态码" msgstr "动态码"
#: authentication/mixins.py:335 #: authentication/mfa/base.py:7
msgid "Please change your password" msgid "Please input security code"
msgstr "请修改密码" msgstr "请输入 6 位动态安全码"
#: authentication/mixins.py:523 #: authentication/mfa/otp.py:7
msgid "OTP code invalid, or server time error"
msgstr "MFA (OTP) 验证码错误,或者服务器端时间不对"
#: authentication/mfa/otp.py:12
msgid "OTP"
msgstr "MFA (OTP)"
#: authentication/mfa/otp.py:47
msgid "Virtual OTP based MFA"
msgstr "虚拟 MFA (OTP)"
#: authentication/mfa/radius.py:7
msgid "Radius verify code invalid"
msgstr "Radius 校验失败"
#: authentication/mfa/radius.py:12
msgid "Radius MFA"
msgstr "Radius MFA"
#: authentication/mfa/radius.py:43
msgid "Radius global enabled, cannot disable"
msgstr "Radius MFA 全局开启,无法被禁用"
#: authentication/mfa/sms.py:7
msgid "SMS verify code invalid"
msgstr "短信验证码校验失败"
#: authentication/mfa/sms.py:12
msgid "SMS" msgid "SMS"
msgstr "短信" msgstr "短信"
#: authentication/mfa/sms.py:13
msgid "SMS verification code"
msgstr "短信验证码"
#: authentication/mfa/sms.py:53
msgid "Set phone number to enable"
msgstr "设置手机号码启用"
#: authentication/mfa/sms.py:57
msgid "Clear phone number to disable"
msgstr "清空手机号码禁用"
#: authentication/mixins.py:305
msgid "The MFA type({}) is not supported"
msgstr "该 MFA 方法 ({}) 不被支持"
#: authentication/mixins.py:334
msgid "Please change your password"
msgstr "请修改密码"
#: authentication/models.py:37 #: authentication/models.py:37
msgid "Private Token" msgid "Private Token"
msgstr "SSH密钥" msgstr "SSH密钥"
@ -1754,18 +1784,6 @@ msgstr "过期时间"
msgid "Different city login reminder" msgid "Different city login reminder"
msgstr "异地登录提醒" msgstr "异地登录提醒"
#: authentication/sms_verify_code.py:15
msgid "The verification code has expired. Please resend it"
msgstr "验证码已过期,请重新发送"
#: authentication/sms_verify_code.py:20
msgid "The verification code is incorrect"
msgstr "验证码错误"
#: authentication/sms_verify_code.py:25
msgid "Please wait {} seconds before sending"
msgstr "请在 {} 秒后发送"
#: authentication/templates/authentication/_access_key_modal.html:6 #: authentication/templates/authentication/_access_key_modal.html:6
msgid "API key list" msgid "API key list"
msgstr "API Key列表" msgstr "API Key列表"
@ -1799,14 +1817,16 @@ msgid "Show"
msgstr "显示" msgstr "显示"
#: authentication/templates/authentication/_access_key_modal.html:66 #: authentication/templates/authentication/_access_key_modal.html:66
#: settings/serializers/security.py:25 users/models/user.py:462 #: settings/serializers/security.py:25 users/models/user.py:458
#: users/serializers/profile.py:99 #: users/serializers/profile.py:99 users/templates/users/mfa_setting.html:60
#: users/templates/users/user_verify_mfa.html:32 #: users/templates/users/user_verify_mfa.html:36
msgid "Disable" msgid "Disable"
msgstr "禁用" msgstr "禁用"
#: authentication/templates/authentication/_access_key_modal.html:67 #: authentication/templates/authentication/_access_key_modal.html:67
#: users/models/user.py:463 users/serializers/profile.py:100 #: users/models/user.py:459 users/serializers/profile.py:100
#: users/templates/users/mfa_setting.html:26
#: users/templates/users/mfa_setting.html:67
msgid "Enable" msgid "Enable"
msgstr "启用" msgstr "启用"
@ -1931,15 +1951,15 @@ msgstr "登录"
msgid "More login options" msgid "More login options"
msgstr "更多登录方式" msgstr "更多登录方式"
#: authentication/templates/authentication/login_otp.html:19 #: authentication/templates/authentication/login_mfa.html:19
#: users/templates/users/user_otp_check_password.html:16 #: users/templates/users/user_otp_check_password.html:18
#: users/templates/users/user_otp_enable_bind.html:24 #: users/templates/users/user_otp_enable_bind.html:24
#: users/templates/users/user_otp_enable_install_app.html:29 #: users/templates/users/user_otp_enable_install_app.html:29
#: users/templates/users/user_verify_mfa.html:26 #: users/templates/users/user_verify_mfa.html:30
msgid "Next" msgid "Next"
msgstr "下一步" msgstr "下一步"
#: authentication/templates/authentication/login_otp.html:21 #: authentication/templates/authentication/login_mfa.html:22
msgid "Can't provide security? Please contact the administrator!" msgid "Can't provide security? Please contact the administrator!"
msgstr "如果不能提供多因子认证验证码,请联系管理员!" msgstr "如果不能提供多因子认证验证码,请联系管理员!"
@ -2055,11 +2075,11 @@ msgid "Please enable cookies and try again."
msgstr "设置你的浏览器支持cookie" msgstr "设置你的浏览器支持cookie"
#: authentication/views/login.py:181 notifications/backends/__init__.py:14 #: authentication/views/login.py:181 notifications/backends/__init__.py:14
#: users/models/user.py:654 #: users/models/user.py:598
msgid "FeiShu" msgid "FeiShu"
msgstr "飞书" msgstr "飞书"
#: authentication/views/login.py:269 #: authentication/views/login.py:270
msgid "" msgid ""
"Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>\n" "Wait for <b>{}</b> confirm, You also can copy link to her/him <br/>\n"
" Don't close this page" " Don't close this page"
@ -2067,15 +2087,15 @@ msgstr ""
"等待 <b>{}</b> 确认, 你也可以复制链接发给他/她 <br/>\n" "等待 <b>{}</b> 确认, 你也可以复制链接发给他/她 <br/>\n"
" 不要关闭本页面" " 不要关闭本页面"
#: authentication/views/login.py:274 #: authentication/views/login.py:275
msgid "No ticket found" msgid "No ticket found"
msgstr "没有发现工单" msgstr "没有发现工单"
#: authentication/views/login.py:306 #: authentication/views/login.py:307
msgid "Logout success" msgid "Logout success"
msgstr "退出登录成功" msgstr "退出登录成功"
#: authentication/views/login.py:307 #: authentication/views/login.py:308
msgid "Logout success, return login page" msgid "Logout success, return login page"
msgstr "退出登录成功,返回到登录页面" msgstr "退出登录成功,返回到登录页面"
@ -2218,26 +2238,38 @@ msgstr "网络错误,请联系系统管理员"
msgid "WeCom error, please contact system administrator" msgid "WeCom error, please contact system administrator"
msgstr "企业微信错误,请联系系统管理员" msgstr "企业微信错误,请联系系统管理员"
#: common/sdk/sms/__init__.py:15
msgid "Alibaba cloud"
msgstr "阿里云"
#: common/sdk/sms/__init__.py:16
msgid "Tencent cloud"
msgstr "腾讯云"
#: common/sdk/sms/__init__.py:42
msgid "SMS provider not support: {}"
msgstr "短信服务商不支持:{}"
#: common/sdk/sms/__init__.py:63
msgid "SMS verification code signature or template invalid"
msgstr "短信验证码签名或模版无效"
#: common/sdk/sms/alibaba.py:56 #: common/sdk/sms/alibaba.py:56
msgid "Signature does not match" msgid "Signature does not match"
msgstr "签名不匹配" msgstr "签名不匹配"
#: common/sdk/sms/endpoint.py:16
msgid "Alibaba cloud"
msgstr "阿里云"
#: common/sdk/sms/endpoint.py:17
msgid "Tencent cloud"
msgstr "腾讯云"
#: common/sdk/sms/endpoint.py:28
msgid "SMS provider not support: {}"
msgstr "短信服务商不支持:{}"
#: common/sdk/sms/endpoint.py:49
msgid "SMS verification code signature or template invalid"
msgstr "短信验证码签名或模版无效"
#: common/sdk/sms/utils.py:15
msgid "The verification code has expired. Please resend it"
msgstr "验证码已过期,请重新发送"
#: common/sdk/sms/utils.py:20
msgid "The verification code is incorrect"
msgstr "验证码错误"
#: common/sdk/sms/utils.py:25
msgid "Please wait {} seconds before sending"
msgstr "请在 {} 秒后发送"
#: common/utils/geoip/utils.py:17 common/utils/geoip/utils.py:30 #: common/utils/geoip/utils.py:17 common/utils/geoip/utils.py:30
msgid "Invalid ip" msgid "Invalid ip"
msgstr "无效IP" msgstr "无效IP"
@ -2302,7 +2334,7 @@ msgstr ""
"div>" "div>"
#: notifications/backends/__init__.py:10 users/forms/profile.py:101 #: notifications/backends/__init__.py:10 users/forms/profile.py:101
#: users/models/user.py:599 #: users/models/user.py:543
msgid "Email" msgid "Email"
msgstr "邮件" msgstr "邮件"
@ -2517,7 +2549,7 @@ msgstr "组织审计员"
msgid "GLOBAL" msgid "GLOBAL"
msgstr "全局组织" msgstr "全局组织"
#: orgs/models.py:434 users/models/user.py:607 users/serializers/user.py:37 #: orgs/models.py:434 users/models/user.py:551 users/serializers/user.py:37
#: users/templates/users/_select_user_modal.html:15 #: users/templates/users/_select_user_modal.html:15
msgid "Role" msgid "Role"
msgstr "角色" msgstr "角色"
@ -2578,7 +2610,7 @@ msgid "Favorite"
msgstr "收藏夹" msgstr "收藏夹"
#: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31 #: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31
#: users/models/user.py:603 users/templates/users/_select_user_modal.html:16 #: users/models/user.py:547 users/templates/users/_select_user_modal.html:16
#: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:39
#: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_asset_permission.html:67
#: users/templates/users/user_database_app_permission.html:38 #: users/templates/users/user_database_app_permission.html:38
@ -2589,7 +2621,7 @@ msgstr "用户组"
#: perms/models/base.py:50 #: perms/models/base.py:50
#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:60 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:60
#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:50 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:50
#: users/models/user.py:635 #: users/models/user.py:579
msgid "Date expired" msgid "Date expired"
msgstr "失效日期" msgstr "失效日期"
@ -3634,7 +3666,7 @@ msgstr "下载更新模版"
msgid "Help" msgid "Help"
msgstr "帮助" msgstr "帮助"
#: templates/_header_bar.html:19 templates/_without_nav_base.html:27 #: templates/_header_bar.html:19
msgid "Docs" msgid "Docs"
msgstr "文档" msgstr "文档"
@ -3737,19 +3769,15 @@ msgstr ""
"\"%(user_pubkey_update)s\"> 链接 </a> 更新\n" "\"%(user_pubkey_update)s\"> 链接 </a> 更新\n"
" " " "
#: templates/_mfa_otp_login.html:14 #: templates/_mfa_login_field.html:28
msgid "Please enter verification code"
msgstr "请输入验证码"
#: templates/_mfa_otp_login.html:16 templates/_mfa_otp_login.html:67
msgid "Send verification code" msgid "Send verification code"
msgstr "发送验证码" msgstr "发送验证码"
#: templates/_mfa_otp_login.html:60 templates/_mfa_otp_login.html:65 #: templates/_mfa_login_field.html:91
msgid "Wait: " msgid "Wait: "
msgstr "等待:" msgstr "等待:"
#: templates/_mfa_otp_login.html:73 #: templates/_mfa_login_field.html:101
msgid "The verification code has been sent" msgid "The verification code has been sent"
msgstr "验证码已发送" msgstr "验证码已发送"
@ -3889,7 +3917,7 @@ msgid ""
"Displays the results of items _START_ to _END_; A total of _TOTAL_ entries" "Displays the results of items _START_ to _END_; A total of _TOTAL_ entries"
msgstr "显示第 _START_ 至 _END_ 项结果; 总共 _TOTAL_ 项" msgstr "显示第 _START_ 至 _END_ 项结果; 总共 _TOTAL_ 项"
#: templates/_without_nav_base.html:25 #: templates/_without_nav_base.html:26
msgid "Home page" msgid "Home page"
msgstr "首页" msgstr "首页"
@ -4845,11 +4873,11 @@ msgstr "点击查看"
msgid "Could not reset self otp, use profile reset instead" msgid "Could not reset self otp, use profile reset instead"
msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置"
#: users/const.py:10 users/models/user.py:171 #: users/const.py:10 users/models/user.py:167
msgid "System administrator" msgid "System administrator"
msgstr "系统管理员" msgstr "系统管理员"
#: users/const.py:11 users/models/user.py:172 #: users/const.py:11 users/models/user.py:168
msgid "System auditor" msgid "System auditor"
msgstr "系统审计员" msgstr "系统审计员"
@ -4940,56 +4968,48 @@ msgstr "不能和原来的密钥相同"
msgid "Not a valid ssh public key" msgid "Not a valid ssh public key"
msgstr "SSH密钥不合法" msgstr "SSH密钥不合法"
#: users/forms/profile.py:160 users/models/user.py:627 #: users/forms/profile.py:160 users/models/user.py:571
#: users/templates/users/user_password_update.html:48 #: users/templates/users/user_password_update.html:48
msgid "Public key" msgid "Public key"
msgstr "SSH公钥" msgstr "SSH公钥"
#: users/models/user.py:36 #: users/models/user.py:460
msgid "One-time password"
msgstr "一次性密码"
#: users/models/user.py:37
msgid "SMS verify code"
msgstr "短信验证码"
#: users/models/user.py:464
msgid "Force enable" msgid "Force enable"
msgstr "强制启用" msgstr "强制启用"
#: users/models/user.py:576 #: users/models/user.py:520
msgid "Local" msgid "Local"
msgstr "数据库" msgstr "数据库"
#: users/models/user.py:610 #: users/models/user.py:554
msgid "Avatar" msgid "Avatar"
msgstr "头像" msgstr "头像"
#: users/models/user.py:613 #: users/models/user.py:557
msgid "Wechat" msgid "Wechat"
msgstr "微信" msgstr "微信"
#: users/models/user.py:624 #: users/models/user.py:568
msgid "Private key" msgid "Private key"
msgstr "ssh私钥" msgstr "ssh私钥"
#: users/models/user.py:643 #: users/models/user.py:587
msgid "Source" msgid "Source"
msgstr "来源" msgstr "来源"
#: users/models/user.py:647 #: users/models/user.py:591
msgid "Date password last updated" msgid "Date password last updated"
msgstr "最后更新密码日期" msgstr "最后更新密码日期"
#: users/models/user.py:650 #: users/models/user.py:594
msgid "Need update password" msgid "Need update password"
msgstr "需要更新密码" msgstr "需要更新密码"
#: users/models/user.py:809 #: users/models/user.py:753
msgid "Administrator" msgid "Administrator"
msgstr "管理员" msgstr "管理员"
#: users/models/user.py:812 #: users/models/user.py:756
msgid "Administrator is the super user of system" msgid "Administrator is the super user of system"
msgstr "Administrator是初始的超级管理员" msgstr "Administrator是初始的超级管理员"
@ -5122,18 +5142,6 @@ msgstr "角色只能为 {}"
msgid "name not unique" msgid "name not unique"
msgstr "名称重复" msgstr "名称重复"
#: users/templates/users/_base_otp.html:14
msgid "Please enter the password of"
msgstr "请输入"
#: users/templates/users/_base_otp.html:14
msgid "account"
msgstr "账户"
#: users/templates/users/_base_otp.html:14
msgid "to complete the binding operation"
msgstr "的密码完成绑定操作"
#: users/templates/users/_granted_assets.html:7 #: users/templates/users/_granted_assets.html:7
msgid "Loading" msgid "Loading"
msgstr "加载中" msgstr "加载中"
@ -5256,6 +5264,18 @@ msgstr "输入您的邮箱, 将会发一封重置邮件到您的邮箱中"
msgid "Submit" msgid "Submit"
msgstr "提交" msgstr "提交"
#: users/templates/users/mfa_setting.html:24
msgid "Enable MFA"
msgstr "启用 MFA 多因子认证"
#: users/templates/users/mfa_setting.html:30
msgid "MFA force enable, cannot disable"
msgstr "MFA 已强制启用,无法禁用"
#: users/templates/users/mfa_setting.html:48
msgid "MFA setting"
msgstr "设置 MFA 多因子认证"
#: users/templates/users/reset_password.html:23 #: users/templates/users/reset_password.html:23
#: users/templates/users/user_password_update.html:64 #: users/templates/users/user_password_update.html:64
msgid "Your password must satisfy" msgid "Your password must satisfy"
@ -5311,13 +5331,24 @@ msgid "Exclude"
msgstr "不包含" msgstr "不包含"
#: users/templates/users/user_otp_check_password.html:6 #: users/templates/users/user_otp_check_password.html:6
#: users/templates/users/user_verify_mfa.html:6 msgid "Enable OTP"
msgid "Authenticate" msgstr "启用 MFA (OTP)"
msgstr "验证身份"
#: users/templates/users/user_otp_check_password.html:10
msgid "Please enter the password of"
msgstr "请输入"
#: users/templates/users/user_otp_check_password.html:10
msgid "account"
msgstr "账户"
#: users/templates/users/user_otp_check_password.html:10
msgid "to complete the binding operation"
msgstr "的密码完成绑定操作"
#: users/templates/users/user_otp_enable_bind.html:6 #: users/templates/users/user_otp_enable_bind.html:6
msgid "Bind one-time password authenticator" msgid "Bind one-time password authenticator"
msgstr "绑定一次性密码验证器" msgstr "绑定MFA验证器"
#: users/templates/users/user_otp_enable_bind.html:13 #: users/templates/users/user_otp_enable_bind.html:13
msgid "" msgid ""
@ -5326,7 +5357,7 @@ msgid ""
msgstr "使用MFA验证器应用扫描以下二维码获取6位验证码" msgstr "使用MFA验证器应用扫描以下二维码获取6位验证码"
#: users/templates/users/user_otp_enable_bind.html:22 #: users/templates/users/user_otp_enable_bind.html:22
#: users/templates/users/user_verify_mfa.html:23 #: users/templates/users/user_verify_mfa.html:27
msgid "Six figures" msgid "Six figures"
msgstr "6位数字" msgstr "6位数字"
@ -5363,38 +5394,49 @@ msgstr "重置"
msgid "Verify password" msgid "Verify password"
msgstr "校验密码" msgstr "校验密码"
#: users/templates/users/user_verify_mfa.html:11 #: users/templates/users/user_verify_mfa.html:9
msgid "Authenticate"
msgstr "验证身份"
#: users/templates/users/user_verify_mfa.html:15
msgid "" msgid ""
"The account protection has been opened, please complete the following " "The account protection has been opened, please complete the following "
"operations according to the prompts" "operations according to the prompts"
msgstr "账号保护已开启,请根据提示完成以下操作" msgstr "账号保护已开启,请根据提示完成以下操作"
#: users/templates/users/user_verify_mfa.html:13 #: users/templates/users/user_verify_mfa.html:17
msgid "Open MFA Authenticator and enter the 6-bit dynamic code" msgid "Open MFA Authenticator and enter the 6-bit dynamic code"
msgstr "请打开MFA验证器输入6位动态码" msgstr "请打开MFA验证器输入6位动态码"
#: users/views/profile/otp.py:122 users/views/profile/otp.py:161 #: users/views/profile/otp.py:80
#: users/views/profile/otp.py:181 msgid "Already bound"
msgid "MFA code invalid, or ntp sync server time" msgstr "已经绑定"
msgstr "MFA验证码不正确或者服务器端时间不对"
#: users/views/profile/otp.py:205 #: users/views/profile/otp.py:81
msgid "MFA enable success" msgid "MFA already bound, disable first, then bound"
msgstr "多因子认证启用成功" msgstr "MFA (OTP) 已经绑定,请先禁用,再绑定"
#: users/views/profile/otp.py:206 #: users/views/profile/otp.py:108
msgid "MFA enable success, return login page" msgid "OTP enable success"
msgstr "多因子认证启用成功,返回到登录页面" msgstr "MFA (OTP) 启用成功"
#: users/views/profile/otp.py:208 #: users/views/profile/otp.py:109
msgid "MFA disable success" msgid "OTP enable success, return login page"
msgstr "多因子认证禁用成功" msgstr "MFA (OTP) 启用成功,返回到登录页面"
#: users/views/profile/otp.py:209 #: users/views/profile/otp.py:151
msgid "MFA disable success, return login page" msgid "Disable OTP"
msgstr "多因子认证禁用成功,返回登录页面" msgstr "禁用 MFA (OTP)"
#: users/views/profile/password.py:32 users/views/profile/password.py:36 #: users/views/profile/otp.py:157
msgid "OTP disable success"
msgstr "MFA (OTP) 禁用成功"
#: users/views/profile/otp.py:158
msgid "OTP disable success, return login page"
msgstr "MFA (OTP) 禁用成功,返回登录页面"
#: users/views/profile/password.py:36 users/views/profile/password.py:41
msgid "Password invalid" msgid "Password invalid"
msgstr "用户名或密码无效" msgstr "用户名或密码无效"
@ -5441,8 +5483,8 @@ msgstr "* 新密码不能是最近 {} 次的密码"
msgid "Reset password success, return to login page" msgid "Reset password success, return to login page"
msgstr "重置密码成功,返回到登录页面" msgstr "重置密码成功,返回到登录页面"
#: xpack/plugins/change_auth_plan/api/app.py:114 #: xpack/plugins/change_auth_plan/api/app.py:113
#: xpack/plugins/change_auth_plan/api/asset.py:101 #: xpack/plugins/change_auth_plan/api/asset.py:100
msgid "The parameter 'action' must be [{}]" msgid "The parameter 'action' must be [{}]"
msgstr "参数 'action' 必须是 [{}]" msgstr "参数 'action' 必须是 [{}]"
@ -5573,15 +5615,15 @@ msgstr "* 请输入正确的密码长度"
msgid "* Password length range 6-30 bits" msgid "* Password length range 6-30 bits"
msgstr "* 密码长度范围 6-30 位" msgstr "* 密码长度范围 6-30 位"
#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:249 #: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:248
msgid "Invalid/incorrect password" msgid "Invalid/incorrect password"
msgstr "无效/错误 密码" msgstr "无效/错误 密码"
#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:251 #: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:250
msgid "Failed to connect to the host" msgid "Failed to connect to the host"
msgstr "连接主机失败" msgstr "连接主机失败"
#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:253 #: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:252
msgid "Data could not be sent to remote" msgid "Data could not be sent to remote"
msgstr "无法将数据发送到远程" msgstr "无法将数据发送到远程"
@ -5939,7 +5981,7 @@ msgstr "执行次数"
msgid "Instance count" msgid "Instance count"
msgstr "实例个数" msgstr "实例个数"
#: xpack/plugins/cloud/utils.py:68 #: xpack/plugins/cloud/utils.py:65
msgid "Account unavailable" msgid "Account unavailable"
msgstr "账户无效" msgstr "账户无效"
@ -6027,6 +6069,38 @@ msgstr "旗舰版"
msgid "Community edition" msgid "Community edition"
msgstr "社区版" msgstr "社区版"
#~ msgid "One-time password invalid, or ntp sync server time"
#~ msgstr "MFA 验证码不正确,或者服务器端时间不对"
#~ msgid "Download MFA APP, Using dynamic code"
#~ msgstr "下载 MFA APP, 使用一次性动态码"
#~ msgid "MFA Radius"
#~ msgstr "Radius MFA"
#~ msgid "Please enter verification code"
#~ msgstr "请输入验证码"
#, python-brace-format
#~ msgid ""
#~ "One-time password invalid, or ntp sync server time, You can also try "
#~ "{times_try} times (The account will be temporarily locked for "
#~ "{block_time} minutes)"
#~ msgstr ""
#~ "虚拟MFA 不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将"
#~ "被临时 锁定 {block_time} 分钟)"
#, python-brace-format
#~ msgid ""
#~ "The MFA type({mfa_type}) is not supported, You can also try {times_try} "
#~ "times (The account will be temporarily locked for {block_time} minutes)"
#~ msgstr ""
#~ "该({mfa_type}) MFA 类型不支持, 您还可以尝试 {times_try} 次(账号将被临时 "
#~ "锁定 {block_time} 分钟)"
#~ msgid "One-time password"
#~ msgstr "一次性密码"
#~ msgid "Go" #~ msgid "Go"
#~ msgstr "立即" #~ msgstr "立即"

View File

@ -68,5 +68,8 @@ class SiteMsgWebsocket(JsonWebsocketConsumer):
def disconnect(self, close_code): def disconnect(self, close_code):
if self.chan is not None: if self.chan is not None:
self.chan.close() try:
self.chan.close()
except:
pass
self.close() self.close()

View File

@ -1174,6 +1174,14 @@ button.dim:active:before {
.onoffswitch-checkbox:checked + .onoffswitch-label .onoffswitch-switch { .onoffswitch-checkbox:checked + .onoffswitch-label .onoffswitch-switch {
right: 0; right: 0;
} }
.onoffswitch-checkbox:disabled + .onoffswitch-label .onoffswitch-inner:before {
background-color: #919191;
}
.onoffswitch-checkbox:disabled + .onoffswitch-label, .onoffswitch-checkbox:disabled + .onoffswitch-label .onoffswitch-switch {
border-color: #919191;
}
/* CHOSEN PLUGIN */ /* CHOSEN PLUGIN */
.chosen-container-single .chosen-single { .chosen-container-single .chosen-single {
background: #ffffff; background: #ffffff;

View File

@ -11,7 +11,6 @@
{% include '_head_css_js.html' %} {% include '_head_css_js.html' %}
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet"> <link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
<script src="{% static "js/jumpserver.js" %}"></script> <script src="{% static "js/jumpserver.js" %}"></script>
<style> <style>
.passwordBox { .passwordBox {
@ -43,5 +42,6 @@
</div> </div>
</div> </div>
</body> </body>
{% include '_foot_js.html' %}
{% block custom_foot_js %} {% endblock %} {% block custom_foot_js %} {% endblock %}
</html> </html>

View File

@ -0,0 +1,117 @@
{% load i18n %}
{% load static %}
<select id="mfa-select" name="mfa_type" class="form-control select-con"
onchange="selectChange(this.value)"
>
{% for backend in mfa_backends %}
<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
{% if backend.challenge_required %}challenge-required{% endif %}"
style="display: none"
>
<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 verification code' %}
</button>
{% endif %}
</div>
{% endfor %}
</div>
<style>
.input-style {
width: 100%;
display: inline-block;
}
.challenge-required .input-style {
width: calc(100% - 114px);
display: inline-block;
}
.btn-challenge {
width: 110px !important;
height: 100%;
vertical-align: top;
}
</style>
<script>
const preferMFAKey = 'mfaPrefer'
$(document).ready(function () {
const mfaSelectRef = document.getElementById('mfa-select');
const preferMFA = localStorage.getItem(preferMFAKey);
if (preferMFA) {
mfaSelectRef.value = preferMFA;
}
const mfaSelect = mfaSelectRef.value;
if (mfaSelect !== null) {
selectChange(mfaSelect, true);
}
})
function selectChange(name, onLoad) {
$('.mfa-field').hide()
$('#mfa-' + name).show()
if (!onLoad) {
localStorage.setItem(preferMFAKey, name)
}
$('.input-style').each(function (i, ele){
$(ele).attr('name', '').attr('required', false)
})
$('#mfa-' + name + ' .input-style').attr('name', 'code').attr('required', true)
}
function sendChallengeCode(currentBtn) {
let time = 60;
const url = "{% url 'api-auth:mfa-select' %}";
const data = {
type: $("#mfa-select").val(),
username: $('input[name="username"]').val()
};
function onSuccess() {
const originBtnText = currentBtn.innerHTML;
currentBtn.disabled = true
const interval = setInterval(function () {
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
time -= 1
if (time === 0) {
currentBtn.innerHTML = originBtnText
currentBtn.disabled = false
clearInterval(interval)
}
}, 1000)
setTimeout(function (){
toastr.success("{% trans 'The verification code has been sent' %}");
})
}
requestApi({
url: url,
method: "POST",
body: JSON.stringify(data),
success: onSuccess,
error: function (text, data) {
toastr.error(data.error)
},
flash_message: false
})
}
</script>

View File

@ -1,81 +0,0 @@
{% load i18n %}
<select id="verify-method-select" name="mfa_type" class="form-control select-con" onchange="selectChange(this.value)">
{% for method in methods %}
<option value="{{ method.name }}"
{% if method.selected %} selected {% endif %}
{% if not method.enable %} disabled {% endif %}
>
{{ method.label }}
</option>
{% endfor %}
</select>
<div class="mfa-div">
<input id="mfa-code" type="text" class="form-control input-style" required name="code"
placeholder="{% trans 'Please enter verification code' %}">
<button id='send-sms-verify-code' type="button" class="btn btn-primary full-width" onclick="sendSMSVerifyCode()"
style="margin-left: 10px!important;height: 100%">{% trans 'Send verification code' %}</button>
</div>
<style type="text/css">
.input-style {
width: calc(100% - 114px);
display: inline-block;
}
#send-sms-verify-code {
width: 110px !important;
height: 100%;
vertical-align: top;
}
</style>
<script>
var methodSelect = document.getElementById('verify-method-select');
if (methodSelect.value !== null) {
selectChange(methodSelect.value);
}
function selectChange(type) {
var otpPlaceholder = '{% trans 'Please enter MFA code' %}';
var smsPlaceholder = '{% trans 'Please enter SMS code' %}';
if (type === "sms") {
$("#mfa-code").css("cssText", "width: calc(100% - 114px)").attr('placeholder', smsPlaceholder);
$("#send-sms-verify-code").css("cssText", "display: inline-block !important");
} else {
$("#mfa-code").css("cssText", "width: 100% !important").attr('placeholder', otpPlaceholder);
$("#send-sms-verify-code").css("cssText", "display: none !important");
}
}
function sendSMSVerifyCode() {
var currentBtn = document.getElementById('send-sms-verify-code');
var time = 60
var url = "{% url 'api-auth:sms-verify-code-send' %}";
var data = {
username: $("#id_username").val()
};
requestApi({
url: url,
method: "POST",
body: JSON.stringify(data),
success: function (data) {
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
currentBtn.disabled = true
currentBtn.classList.add("disabledBtn")
var TimeInterval = setInterval(() => {
--time
currentBtn.innerHTML = `{% trans 'Wait: ' %} ${time}`;
if (time === 0) {
currentBtn.innerHTML = "{% trans 'Send verification code' %}"
currentBtn.disabled = false
currentBtn.classList.remove("disabledBtn")
clearInterval(TimeInterval)
}
}, 1000)
alert("{% trans 'The verification code has been sent' %}");
},
error: function (text, data) {
alert(data.detail)
},
flash_message: false
})
}
</script>

View File

@ -7,26 +7,23 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title> {{ JMS_TITLE }} </title> <title> {{ JMS_TITLE }} </title>
<link rel="shortcut icon" href="{{ FAVICON_URL }}" type="image/x-icon"> <link rel="shortcut icon" href="{{ FAVICON_URL }}" type="image/x-icon">
{# <link rel="stylesheet" href="{% static 'fonts/font_otp/iconfont.css' %}" />#} {% include '_head_css_js.html' %}
<link href="{% static 'css/jumpserver.css' %}" rel="stylesheet">
<link href="{% static 'css/style.css' %}" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/otp.css' %}" /> <link rel="stylesheet" href="{% static 'css/otp.css' %}" />
<script src="{% static 'js/jquery-3.1.1.min.js' %}"></script>
<script src="{% static "js/plugins/qrcode/qrcode.min.js" %}"></script> <script src="{% static "js/plugins/qrcode/qrcode.min.js" %}"></script>
</head> </head>
<body> <body style="background-color: #f3f3f4">
<header> <header>
<div class="logo"> <div class="logo">
<a href="{% url 'index' %}"> <a href="{% url 'index' %}">
<img src="{{ LOGO_URL }}" alt="" width="50px" height="50px"/> <img src="{{ LOGO_URL }}" alt="" width="50px" height="50px"/>
</a> </a>
<a href="{% url 'index' %}">{{ JMS_TITLE }}</a> <span style="font-size: 18px; line-height: 50px">{{ JMS_TITLE }}</span>
</div> </div>
<div> <div>
<a href="{% url 'index' %}">{% trans 'Home page' %}</a> <a href="{% url 'index' %}">{% trans 'Home page' %}</a>
<b></b>
<a href="http://docs.jumpserver.org/zh/docs/">{% trans 'Docs' %}</a>
<b></b>
<a href="https://www.github.com/jumpserver/">GitHub</a>
</div> </div>
</header> </header>
<body> <body>
@ -34,10 +31,11 @@
{% endblock %} {% endblock %}
</body> </body>
<footer> <footer>
<div class="" style="margin-top: 100px;"> <div style="margin-top: 100px;">
{% include '_copyright.html' %} {% include '_copyright.html' %}
</div> </div>
</footer> </footer>
{% include '_foot_js.html' %}
</body> </body>
</html> </html>

View File

View File

@ -12,33 +12,28 @@ from django.contrib.auth.models import AbstractUser
from django.contrib.auth.hashers import check_password from django.contrib.auth.hashers import check_password
from django.core.cache import cache from django.core.cache import cache
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.shortcuts import reverse from django.shortcuts import reverse
from orgs.utils import current_org from orgs.utils import current_org
from orgs.models import OrganizationMember, Organization from orgs.models import OrganizationMember, Organization
from common.exceptions import JMSException
from common.utils import date_expired_default, get_logger, lazyproperty, random_string from common.utils import date_expired_default, get_logger, lazyproperty, random_string
from common import fields from common import fields
from common.const import choices from common.const import choices
from common.db.models import TextChoices from common.db.models import TextChoices
from users.exceptions import MFANotEnabled, PhoneNotSet
from ..signals import post_user_change_password from ..signals import post_user_change_password
__all__ = ['User', 'UserPasswordHistory', 'MFAType'] __all__ = ['User', 'UserPasswordHistory']
logger = get_logger(__file__) logger = get_logger(__file__)
class MFAType(TextChoices):
OTP = 'otp', _('One-time password')
SMS_CODE = 'sms', _('SMS verify code')
class AuthMixin: class AuthMixin:
date_password_last_updated: datetime.datetime date_password_last_updated: datetime.datetime
history_passwords: models.Manager
need_update_password: bool
public_key: str
is_local: bool is_local: bool
@property @property
@ -77,7 +72,8 @@ class AuthMixin:
def is_history_password(self, password): def is_history_password(self, password):
allow_history_password_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT allow_history_password_count = settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT
history_passwords = self.history_passwords.all().order_by('-date_created')[:int(allow_history_password_count)] history_passwords = self.history_passwords.all() \
.order_by('-date_created')[:int(allow_history_password_count)]
for history_password in history_passwords: for history_password in history_passwords:
if check_password(password, history_password.password): if check_password(password, history_password.password):
@ -474,9 +470,11 @@ class MFAMixin:
@property @property
def mfa_force_enabled(self): def mfa_force_enabled(self):
if settings.SECURITY_MFA_AUTH in [True, 1]: force_level = settings.SECURITY_MFA_AUTH
if force_level in [True, 1]:
return True return True
if settings.SECURITY_MFA_AUTH == 2 and self.is_org_admin: # 2 管理员强制开启
if force_level == 2 and self.is_org_admin:
return True return True
return self.mfa_level == 2 return self.mfa_level == 2
@ -489,86 +487,32 @@ class MFAMixin:
def disable_mfa(self): def disable_mfa(self):
self.mfa_level = 0 self.mfa_level = 0
self.otp_secret_key = None
def reset_mfa(self): def no_active_mfa(self):
if self.mfa_is_otp(): return len(self.active_mfa_backends) == 0
self.otp_secret_key = ''
@lazyproperty
def active_mfa_backends(self):
backends = self.get_user_mfa_backends(self)
active_backends = [b for b in backends if b.is_active()]
return active_backends
@property
def active_mfa_backends_mapper(self):
return {b.name: b for b in self.active_mfa_backends}
@staticmethod @staticmethod
def mfa_is_otp(): def get_user_mfa_backends(user):
if settings.OTP_IN_RADIUS: from authentication.mfa import MFA_BACKENDS
return False backends = [cls(user) for cls in MFA_BACKENDS if cls.global_enabled()]
return True return backends
def check_radius(self, code): def get_mfa_backend_by_type(self, mfa_type):
from authentication.backends.radius import RadiusBackend mfa_mapper = self.active_mfa_backends_mapper
backend = RadiusBackend() backend = mfa_mapper.get(mfa_type)
user = backend.authenticate(None, username=self.username, password=code) if not backend:
if user: return None
return True return backend
return False
def check_otp(self, code):
from ..utils import check_otp_code
return check_otp_code(self.otp_secret_key, code)
def check_mfa(self, code, mfa_type=MFAType.OTP):
if not self.mfa_enabled:
raise MFANotEnabled
if mfa_type == MFAType.OTP:
if settings.OTP_IN_RADIUS:
return self.check_radius(code)
else:
return self.check_otp(code)
elif mfa_type == MFAType.SMS_CODE:
return self.check_sms_code(code)
def get_supported_mfa_types(self):
methods = []
if self.otp_secret_key:
methods.append(MFAType.OTP)
if settings.XPACK_ENABLED and settings.SMS_ENABLED and self.phone:
methods.append(MFAType.SMS_CODE)
return methods
def check_sms_code(self, code):
from authentication.sms_verify_code import VerifyCodeUtil
if not self.phone:
raise PhoneNotSet
try:
util = VerifyCodeUtil(self.phone)
return util.verify(code)
except JMSException:
return False
def send_sms_code(self):
from authentication.sms_verify_code import VerifyCodeUtil
if not self.phone:
raise PhoneNotSet
util = VerifyCodeUtil(self.phone)
util.touch()
return util.timeout
def mfa_enabled_but_not_set(self):
if not self.mfa_enabled:
return False, None
if not self.mfa_is_otp():
return False, None
if self.mfa_is_otp() and self.otp_secret_key:
return False, None
if self.phone and settings.SMS_ENABLED and settings.XPACK_ENABLED:
return False, None
return True, reverse('authentication:user-otp-enable-start')
class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):

View File

@ -11,8 +11,6 @@
</h2> </h2>
</div> </div>
<div> <div>
<div class="verify">{% trans 'Please enter the password of' %}&nbsp;{% trans 'account' %}&nbsp;<span>{{ user.username }}</span>&nbsp;{% trans 'to complete the binding operation' %}</div>
<hr style="width: 500px; margin: auto; margin-top: 10px;">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>

View File

@ -0,0 +1,116 @@
{% extends '_without_nav_base.html' %}
{% load static %}
{% load i18n %}
{% block body %}
<style>
.help-inline {
color: #7d8293;
font-size: 12px;
padding-right: 10px;
}
.btn-xs {
width: 54px;
}
.onoffswitch-switch {
height: 20px;
}
</style>
<article>
<div>
{# // Todoi:#}
<h3>{% trans 'Enable MFA' %}</h3>
<div class="row" style="padding-top: 10px">
<li class="col-sm-6" style="font-size: 14px">{% trans 'Enable' %} MFA</li>
<div class="switch col-sm-6">
<span class="help-inline">
{% if user.mfa_force_enabled %}
{% trans 'MFA force enable, cannot disable' %}
{% endif %}
</span>
<div class="onoffswitch" style="float: right">
<input type="checkbox" class="onoffswitch-checkbox"
id="mfa-switch" onchange="switchMFA()"
{% if user.mfa_force_enabled %} disabled {% endif %}
{% if user.mfa_enabled %} checked {% endif %}
>
<label class="onoffswitch-label" for="mfa-switch">
<span class="onoffswitch-inner"></span>
<span class="onoffswitch-switch"></span>
</label>
</div>
</div>
</div>
</div>
<div id="mfa-setting" style="display: none; padding-top: 30px">
<h3>{% trans 'MFA setting' %}</h3>
<div style="height: 100%; width: 100%;">
{% for b in mfa_backends %}
<div class="row" style="padding-top: 10px">
<li class="col-sm-6" style="font-size: 14px">{{ b.display_name }}
{{ b.enable }}</li>
<span class="col-sm-6">
{% if b.is_active %}
<button class="btn btn-warning btn-xs" style="float: right"
{% if not b.can_disable %} disabled {% endif %}
onclick="goTo('{{ b.get_disable_url }}')"
>
{% trans 'Disable' %}
</button>
<span class="help-inline">{{ b.help_text_of_disable }}</span>
{% else %}
<button class="btn btn-primary btn-xs" style="float: right"
onclick="goTo('{{ b.get_enable_url }}')"
>
{% trans 'Enable' %}
</button>
<span class="help-inline">{{ b.help_text_of_enable }}</span>
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
</article>
<script src="{% static 'js/jumpserver.js' %}"></script>
<script>
function goTo(url) {
window.open(url, '_self')
}
function switchMFA() {
const switchRef = $('#mfa-switch')
const enabled = switchRef.is(":checked")
requestApi({
url: '/api/v1/users/profile/',
data: {
mfa_level: enabled ? 1 : 0
},
method: 'PATCH',
success() {
showSettingOrNot()
},
error() {
switchRef.prop('checked', !enabled)
}
})
showSettingOrNot()
}
function showSettingOrNot() {
const enabled = $('#mfa-switch').is(":checked")
const settingRef = $('#mfa-setting')
if (enabled) {
settingRef.show()
} else {
settingRef.hide()
}
}
window.onload = function () {
showSettingOrNot()
}
</script>
{% endblock %}

View File

@ -3,10 +3,12 @@
{% load i18n %} {% load i18n %}
{% block small_title %} {% block small_title %}
{% trans 'Authenticate' %} {% trans 'Enable OTP' %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="verify">{% trans 'Please enter the password of' %}&nbsp;{% trans 'account' %}&nbsp;<span>{{ user.username }}</span>&nbsp;{% trans 'to complete the binding operation' %}</div>
<hr style="width: 500px; margin: auto; margin-top: 10px;">
<form id="verify-form" class="" role="form" method="post" action=""> <form id="verify-form" class="" role="form" method="post" action="">
{% csrf_token %} {% csrf_token %}
<div class="form-input"> <div class="form-input">

View File

@ -3,7 +3,11 @@
{% load i18n %} {% load i18n %}
{% block small_title %} {% block small_title %}
{% trans 'Authenticate' %} {% if title %}
{{ title }}
{% else %}
{% trans 'Authenticate' %}
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -137,7 +137,7 @@ class BlockUtilBase:
times_remainder = int(times_up) - int(times_failed) times_remainder = int(times_up) - int(times_failed)
return times_remainder return times_remainder
def incr_failed_count(self): def incr_failed_count(self) -> int:
limit_key = self.limit_key limit_key = self.limit_key
count = cache.get(limit_key, 0) count = cache.get(limit_key, 0)
count += 1 count += 1
@ -146,6 +146,7 @@ class BlockUtilBase:
limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT
if count >= limit_count: if count >= limit_count:
cache.set(self.block_key, True, self.key_ttl) cache.set(self.block_key, True, self.key_ttl)
return limit_count - count
def get_failed_count(self): def get_failed_count(self):
count = cache.get(self.limit_key, 0) count = cache.get(self.limit_key, 0)
@ -205,4 +206,4 @@ def is_auth_password_time_valid(session):
def is_auth_otp_time_valid(session): def is_auth_otp_time_valid(session):
return is_auth_time_valid(session, 'auth_opt_expired_at') return is_auth_time_valid(session, 'auth_otp_expired_at')

View File

@ -1,2 +1,24 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
from __future__ import unicode_literals
from django.views.generic.base import TemplateView
from common.permissions import IsValidUser
from common.mixins.views import PermissionsMixin
from users.models import User
__all__ = ['MFASettingView']
class MFASettingView(PermissionsMixin, TemplateView):
template_name = 'users/mfa_setting.html'
permission_classes = [IsValidUser]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
mfa_backends = User.get_user_mfa_backends(self.request.user)
context.update({
'mfa_backends': mfa_backends,
})
return context

View File

@ -1,31 +1,30 @@
# ~*~ coding: utf-8 ~*~ # ~*~ coding: utf-8 ~*~
import time import time
from django.urls import reverse_lazy, reverse from django.urls import reverse
from django.shortcuts import get_object_or_404
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from django.contrib.auth import logout as auth_logout from django.contrib.auth import logout as auth_logout
from django.conf import settings from django.http.response import HttpResponseRedirect
from django.http.response import HttpResponseForbidden
from authentication.mixins import AuthMixin from authentication.mixins import AuthMixin
from users.models import User from authentication.mfa import MFAOtp, otp_failed_msg
from common.utils import get_logger, get_object_or_none from common.utils import get_logger, FlashMessageUtil
from common.mixins.views import PermissionsMixin
from common.permissions import IsValidUser from common.permissions import IsValidUser
from ... import forms
from .password import UserVerifyPasswordView from .password import UserVerifyPasswordView
from ... import forms
from ...utils import ( from ...utils import (
generate_otp_uri, check_otp_code, get_user_or_pre_auth_user, generate_otp_uri, check_otp_code,
is_auth_password_time_valid, is_auth_otp_time_valid get_user_or_pre_auth_user,
) )
__all__ = [ __all__ = [
'UserOtpEnableStartView', 'UserOtpEnableStartView',
'UserOtpEnableInstallAppView', 'UserOtpEnableInstallAppView',
'UserOtpEnableBindView', 'UserOtpSettingsSuccessView', 'UserOtpEnableBindView',
'UserDisableMFAView', 'UserOtpUpdateView', 'UserOtpDisableView',
] ]
logger = get_logger(__name__) logger = get_logger(__name__)
@ -34,22 +33,8 @@ logger = get_logger(__name__)
class UserOtpEnableStartView(UserVerifyPasswordView): class UserOtpEnableStartView(UserVerifyPasswordView):
template_name = 'users/user_otp_check_password.html' template_name = 'users/user_otp_check_password.html'
def form_valid(self, form):
# 开启了 OTP IN RADIUS 就不用绑定了
resp = super().form_valid(form)
if settings.OTP_IN_RADIUS:
user_id = self.request.session.get('user_id')
user = get_object_or_404(User, id=user_id)
user.enable_mfa()
user.save()
return resp
def get_success_url(self): def get_success_url(self):
if settings.OTP_IN_RADIUS: return reverse('authentication:user-otp-enable-install-app')
success_url = reverse_lazy('authentication:user-otp-settings-success')
else:
success_url = reverse('authentication:user-otp-enable-install-app')
return success_url
class UserOtpEnableInstallAppView(TemplateView): class UserOtpEnableInstallAppView(TemplateView):
@ -65,69 +50,68 @@ class UserOtpEnableInstallAppView(TemplateView):
class UserOtpEnableBindView(AuthMixin, TemplateView, FormView): class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
template_name = 'users/user_otp_enable_bind.html' template_name = 'users/user_otp_enable_bind.html'
form_class = forms.UserCheckOtpCodeForm form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-settings-success')
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if self._check_can_bind(): pre_response = self._pre_check_can_bind()
return super().get(request, *args, **kwargs) if pre_response:
return HttpResponseForbidden() return pre_response
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if self._check_can_bind(): pre_response = self._pre_check_can_bind()
return super().post(request, *args, **kwargs) if pre_response:
return HttpResponseForbidden() return pre_response
return super().post(request, *args, **kwargs)
def _check_authenticated_user_can_bind(self): def _pre_check_can_bind(self):
user = self.request.user try:
session = self.request.session user = self.get_user_from_session()
except:
verify_url = reverse('authentication:user-otp-enable-start')
return HttpResponseRedirect(verify_url)
if not user.mfa_enabled: if user.otp_secret_key:
return is_auth_password_time_valid(session) return self.has_already_bound_message()
return None
if not user.otp_secret_key: @staticmethod
return is_auth_password_time_valid(session) def has_already_bound_message():
message_data = {
return is_auth_otp_time_valid(session) 'title': _('Already bound'),
'error': _('MFA already bound, disable first, then bound'),
def _check_unauthenticated_user_can_bind(self): 'interval': 10,
session_user = None 'redirect_url': reverse('authentication:user-otp-disable'),
if not self.request.session.is_empty(): }
user_id = self.request.session.get('user_id') response = FlashMessageUtil.gen_and_redirect_to(message_data)
session_user = get_object_or_none(User, pk=user_id) return response
if session_user:
if all((
is_auth_password_time_valid(self.request.session),
session_user.mfa_enabled,
not session_user.otp_secret_key
)):
return True
return False
def _check_can_bind(self):
if self.request.user.is_authenticated:
return self._check_authenticated_user_can_bind()
else:
return self._check_unauthenticated_user_can_bind()
def form_valid(self, form): def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code') otp_code = form.cleaned_data.get('otp_code')
otp_secret_key = self.request.session.get('otp_secret_key', '') otp_secret_key = self.request.session.get('otp_secret_key', '')
valid = check_otp_code(otp_secret_key, otp_code) valid = check_otp_code(otp_secret_key, otp_code)
if valid: if not valid:
self.save_otp(otp_secret_key) form.add_error("otp_code", otp_failed_msg)
return super().form_valid(form)
else:
error = _("MFA code invalid, or ntp sync server time")
form.add_error("otp_code", error)
return self.form_invalid(form) return self.form_invalid(form)
self.save_otp(otp_secret_key)
auth_logout(self.request)
return super().form_valid(form)
def save_otp(self, otp_secret_key): def save_otp(self, otp_secret_key):
user = get_user_or_pre_auth_user(self.request) user = get_user_or_pre_auth_user(self.request)
user.enable_mfa()
user.otp_secret_key = otp_secret_key user.otp_secret_key = otp_secret_key
user.save() user.save(update_fields=['otp_secret_key'])
def get_success_url(self):
message_data = {
'title': _('OTP enable success'),
'message': _('OTP enable success, return login page'),
'interval': 5,
'redirect_url': reverse('authentication:login'),
}
url = FlashMessageUtil.gen_message_url(message_data)
return url
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
user = get_user_or_pre_auth_user(self.request) user = get_user_or_pre_auth_user(self.request)
@ -142,70 +126,40 @@ class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
class UserDisableMFAView(FormView): class UserOtpDisableView(PermissionsMixin, FormView):
template_name = 'users/user_verify_mfa.html' template_name = 'users/user_verify_mfa.html'
form_class = forms.UserCheckOtpCodeForm form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-settings-success')
permission_classes = [IsValidUser] permission_classes = [IsValidUser]
def form_valid(self, form): def form_valid(self, form):
user = self.request.user user = self.request.user
otp_code = form.cleaned_data.get('otp_code') otp_code = form.cleaned_data.get('otp_code')
otp = MFAOtp(user)
valid = user.check_mfa(otp_code) ok, error = otp.check_code(otp_code)
if valid: if not ok:
user.disable_mfa()
user.save()
return super().form_valid(form)
else:
error = _('MFA code invalid, or ntp sync server time')
form.add_error('otp_code', error) form.add_error('otp_code', error)
return super().form_invalid(form) return super().form_invalid(form)
otp.disable()
class UserOtpUpdateView(FormView): auth_logout(self.request)
template_name = 'users/user_verify_mfa.html' return super().form_valid(form)
form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-enable-bind')
permission_classes = [IsValidUser]
def form_valid(self, form):
user = self.request.user
otp_code = form.cleaned_data.get('otp_code')
valid = user.check_mfa(otp_code)
if valid:
self.request.session['auth_opt_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
return super().form_valid(form)
else:
error = _('MFA code invalid, or ntp sync server time')
form.add_error('otp_code', error)
return super().form_invalid(form)
class UserOtpSettingsSuccessView(TemplateView):
template_name = 'flash_message_standalone.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
title, describe = self.get_title_describe() context = super().get_context_data(**kwargs)
context = { context.update({
'title': title, 'title': _("Disable OTP")
'message': describe, })
'interval': 1, return context
def get_success_url(self):
message_data = {
'title': _('OTP disable success'),
'message': _('OTP disable success, return login page'),
'interval': 5,
'redirect_url': reverse('authentication:login'), 'redirect_url': reverse('authentication:login'),
'auto_redirect': True,
} }
kwargs.update(context) url = FlashMessageUtil.gen_message_url(message_data)
return super().get_context_data(**kwargs) return url
def get_title_describe(self):
user = get_user_or_pre_auth_user(self.request)
if self.request.user.is_authenticated:
auth_logout(self.request)
title = _('MFA enable success')
describe = _('MFA enable success, return login page')
if not user.mfa_enabled:
title = _('MFA disable success')
describe = _('MFA disable success, return login page')
return title, describe

View File

@ -6,7 +6,8 @@ from django.contrib.auth import authenticate
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from authentication.mixins import PasswordEncryptionViewMixin
from authentication.mixins import PasswordEncryptionViewMixin, AuthMixin
from authentication import errors from authentication import errors
from common.utils import get_logger from common.utils import get_logger
@ -20,24 +21,27 @@ __all__ = ['UserVerifyPasswordView']
logger = get_logger(__name__) logger = get_logger(__name__)
class UserVerifyPasswordView(PasswordEncryptionViewMixin, FormView): class UserVerifyPasswordView(AuthMixin, FormView):
template_name = 'users/user_password_verify.html' template_name = 'users/user_password_verify.html'
form_class = forms.UserCheckPasswordForm form_class = forms.UserCheckPasswordForm
def form_valid(self, form): def form_valid(self, form):
user = get_user_or_pre_auth_user(self.request) user = get_user_or_pre_auth_user(self.request)
if user is None:
return redirect('authentication:login')
try: try:
password = self.get_decrypted_password(username=user.username) password = self.get_decrypted_password(username=user.username)
except errors.AuthFailedError as e: except errors.AuthFailedError as e:
form.add_error("password", _(f"Password invalid") + f'({e.msg})') form.add_error("password", _(f"Password invalid") + f'({e.msg})')
return self.form_invalid(form) return self.form_invalid(form)
user = authenticate(request=self.request, username=user.username, password=password) user = authenticate(request=self.request, username=user.username, password=password)
if not user: if not user:
form.add_error("password", _("Password invalid")) form.add_error("password", _("Password invalid"))
return self.form_invalid(form) return self.form_invalid(form)
self.request.session['user_id'] = str(user.id)
self.request.session['auth_password'] = 1 self.mark_password_ok(user)
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
return redirect(self.get_success_url()) return redirect(self.get_success_url())
def get_success_url(self): def get_success_url(self):