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>
pull/7163/head
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 -*-
#
import builtins
import time
from django.utils.translation import ugettext as _
@ -12,55 +11,76 @@ from rest_framework.serializers import ValidationError
from rest_framework.response import Response
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 .. import serializers
from .. import errors
from ..mfa.otp import MFAOtp
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,)
serializer_class = serializers.MFASelectTypeSerializer
def perform_create(self, serializer):
username = serializer.validated_data.get('username', '')
mfa_type = serializer.validated_data['type']
if mfa_type == MFAType.SMS_CODE:
if not username:
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,)
serializer_class = serializers.MFAChallengeSerializer
def perform_create(self, serializer):
try:
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
mfa_type = serializer.validated_data.get('type', '')
self._do_check_user_mfa(code, mfa_type, user)
valid = user.check_mfa(code, mfa_type=mfa_type)
if not valid:
self.request.session['auth_mfa'] = ''
raise errors.MFAFailedError(
username=user.username, request=self.request, ip=self.get_request_ip()
)
else:
self.request.session['auth_mfa'] = '1'
def create(self, request, *args, **kwargs):
try:
super().create(request, *args, **kwargs)
return Response({'msg': 'ok'})
except errors.AuthFailedError as e:
data = {"error": e.error, "msg": e.msg}
raise ValidationError(data)
except errors.NeedMoreInfoError as e:
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):
permission_classes = (IsValidUser,)
@ -73,30 +93,17 @@ class UserOtpVerifyApi(CreateAPIView):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
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())
return Response({"ok": "1"})
else:
return Response({"error": _("Code is invalid")}, status=400)
return Response({"error": _("Code is invalid") + ", " + error}, status=400)
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]
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 common.permissions import IsValidUser
from authentication.mixins import authenticate
from authentication.errors import PasswdInvalid
from authentication.errors import PasswordInvalid
from authentication.mixins import AuthMixin
@ -20,7 +20,7 @@ class UserPasswordVerifyApi(AuthMixin, CreateAPIView):
user = authenticate(request=request, username=user.username, password=password)
if not user:
raise PasswdInvalid
raise PasswordInvalid
self.set_passwd_verify_on_session(user)
self.mark_password_ok(user)
return Response()

View File

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

View File

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

View File

@ -1,24 +1,26 @@
# -*- coding: utf-8 -*-
#
import inspect
from django.utils.http import urlencode
from functools import partial
import time
from typing import Callable
from django.utils.http import urlencode
from django.core.cache import cache
from django.conf import settings
from django.urls import reverse_lazy
from django.contrib import auth
from django.utils.translation import ugettext as _
from rest_framework.request import Request
from django.contrib.auth import (
BACKEND_SESSION_KEY, _get_backends,
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 acls.models import LoginACL
from users.models import User, MFAType
from users.models import User
from users.utils import LoginBlockUtil, MFABlockUtils
from . import errors
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:
logger.debug('Skip user auth backend: {}, {} not in'.format(
username, backend_path, ','.join(allowed_auth_backends)
)
)
))
return False
return True
@ -109,17 +110,18 @@ class PasswordEncryptionViewMixin:
def decrypt_passwd(self, raw_passwd):
# 获取解密密钥,对密码进行解密
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
if rsa_private_key is not None:
try:
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error(
f'Decrypt password failed: password[{raw_passwd}] '
f'rsa_private_key[{rsa_private_key}]'
)
return None
return raw_passwd
if rsa_private_key is None:
return raw_passwd
try:
return rsa_decrypt(raw_passwd, rsa_private_key)
except Exception as e:
logger.error(e, exc_info=True)
logger.error(
f'Decrypt password failed: password[{raw_passwd}] '
f'rsa_private_key[{rsa_private_key}]'
)
return None
def get_request_ip(self):
ip = ''
@ -132,7 +134,7 @@ class PasswordEncryptionViewMixin:
# 生成加解密密钥对public_key传递给前端private_key存入session中供解密使用
rsa_public_key = self.request.session.get(RSA_PUBLIC_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_public_key = rsa_public_key.replace('\n', '\\n')
self.request.session[RSA_PRIVATE_KEY] = rsa_private_key
@ -144,49 +146,9 @@ class PasswordEncryptionViewMixin:
return super().get_context_data(**kwargs)
class AuthMixin(PasswordEncryptionViewMixin):
request = None
partial_credential_error = None
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)
class CommonMixin(PasswordEncryptionViewMixin):
request: Request
get_request_ip: Callable
def raise_credential_error(self, error):
raise self.partial_credential_error(error=error)
@ -197,6 +159,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
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):
request = self.request
if hasattr(request, 'data'):
@ -214,6 +201,31 @@ class AuthMixin(PasswordEncryptionViewMixin):
password = password + challenge.strip()
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):
# 仅允许预先存在的用户认证
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}")
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
if hasattr(request, 'data'):
data = request.data
else:
data = request.POST
data = request.data if hasattr(request, 'data') else request.POST
code = data.get('code')
mfa_type = data.get('mfa_type')
if settings.SECURITY_MFA_IN_LOGIN_PAGE and mfa_type:
if not code:
if mfa_type == MFAType.OTP and bool(user.otp_secret_key):
raise errors.OTPCodeRequiredError
elif mfa_type == MFAType.SMS_CODE:
raise errors.SMSCodeRequiredError
self.check_user_mfa(code, mfa_type, user=user)
mfa_type = data.get('mfa_type', 'otp')
if not code:
raise errors.MFACodeRequiredError
self._do_check_user_mfa(code, mfa_type, user=user)
def _check_login_acl(self, user, ip):
# ACL 限制用户登录
is_allowed, limit_type = LoginACL.allow_user_to_login(user, ip)
if not is_allowed:
if limit_type == 'ip':
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
elif limit_type == 'time':
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
def check_user_mfa_if_need(self, user):
if self.request.session.get('auth_mfa'):
return
if not user.mfa_enabled:
return
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()
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):
self.request.session['user_id'] = str(user.id)
self.request.session['auth_password'] = 1
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
ok = False
mfa_backend = user.get_mfa_backend_by_type(mfa_type)
if mfa_backend:
ok, msg = mfa_backend.check_code(code)
else:
msg = _('The MFA type({}) is not supported'.format(mfa_type))
def check_is_need_captcha(self):
# 最近有登录失败时需要填写验证码
ip = get_request_ip(self.request)
need = cache.get(self.key_prefix_captcha.format(ip))
return need
if ok:
self.mark_mfa_ok(mfa_type)
return
def check_user_auth(self, decrypt_passwd=False):
self.check_is_block()
username, password, public_key, ip, auto_login = self.get_auth_data(decrypt_passwd)
raise errors.MFAFailedError(
username=user.username,
request=self.request,
ip=ip, mfa_type=mfa_type,
error=msg
)
self._check_only_allow_exists_user_auth(username)
user = self._check_auth_user_is_valid(username, password, public_key)
# 校验login-acl规则
self._check_login_acl(user, ip)
self._check_password_require_reset_or_not(user)
self._check_passwd_is_too_simple(user, password)
self._check_passwd_need_update(user)
@staticmethod
def get_user_mfa_context(user=None):
mfa_backends = User.get_user_mfa_backends(user)
return {'mfa_backends': mfa_backends}
# 校验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
def generate_reset_password_url_with_flash_msg(cls, user, message):
reset_passwd_url = reverse('authentication:reset-password')
@ -344,14 +343,14 @@ class AuthMixin(PasswordEncryptionViewMixin):
if user.is_superuser and password == 'admin':
message = _('Your password is too simple, please change it for security')
url = cls.generate_reset_password_url_with_flash_msg(user, message=message)
raise errors.PasswdTooSimple(url)
raise errors.PasswordTooSimple(url)
@classmethod
def _check_passwd_need_update(cls, user: User):
if user.need_update_password:
message = _('You should to change your password before login')
url = cls.generate_reset_password_url_with_flash_msg(user, message)
raise errors.PasswdNeedUpdate(url)
raise errors.PasswordNeedUpdate(url)
@classmethod
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)
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):
if self.request.session.get('auth_mfa'):
class AuthACLMixin:
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
if settings.OTP_IN_RADIUS:
return
if not user.mfa_enabled:
return
unset, url = user.mfa_enabled_but_not_set()
if unset:
raise errors.MFAUnsetError(user, self.request, url)
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,
)
if limit_type == 'ip':
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
elif limit_type == 'time':
raise errors.TimePeriodNotAllowed(username=user.username, request=self.request)
def get_ticket(self):
from tickets.models import Ticket
@ -480,11 +423,99 @@ class AuthMixin(PasswordEncryptionViewMixin):
self.get_ticket_or_create(confirm_setting)
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):
self.request.session['auth_password'] = ''
self.request.session['auth_user_id'] = ''
self.request.session['auth_confirm'] = ''
self.request.session['auth_ticket_id'] = ''
keys = ['auth_password', 'user_id', 'auth_confirm', 'auth_ticket_id']
for k in keys:
self.request.session.pop(k, '')
def send_auth_signal(self, success=True, user=None, username='', reason=''):
if success:
@ -503,31 +534,3 @@ class AuthMixin(PasswordEncryptionViewMixin):
if args:
guard_url = "%s?%s" % (guard_url, args)
return redirect(guard_url)
@staticmethod
def get_user_mfa_methods(user=None):
otp_enabled = user.otp_secret_key if user else True
# 没有用户时,或者有用户并且有电话配置
sms_enabled = any([user and user.phone, not user]) \
and settings.SMS_ENABLED and settings.XPACK_ENABLED
methods = [
{
'name': 'otp',
'label': 'MFA',
'enable': otp_enabled,
'selected': False,
},
{
'name': 'sms',
'label': _('SMS'),
'enable': sms_enabled,
'selected': False,
},
]
for item in methods:
if item['enable']:
item['selected'] = True
break
return methods

View File

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

View File

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

View File

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

View File

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

View File

@ -25,10 +25,11 @@ urlpatterns = [
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('mfa/verify/', api.MFAChallengeVerifyApi.as_view(), name='mfa-verify'),
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('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('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
]

View File

@ -12,7 +12,7 @@ app_name = 'authentication'
urlpatterns = [
# 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/guard/', views.UserLoginGuardView.as_view(), name='login-guard'),
path('logout/', views.UserLogoutView.as_view(), name='logout'),
@ -42,14 +42,15 @@ urlpatterns = [
# Profile
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/install-app/', users_view.UserOtpEnableInstallAppView.as_view(),
name='user-otp-enable-install-app'),
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(),
name='user-otp-disable-authentication'),
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('profile/otp/disable/', users_view.UserOtpDisableView.as_view(),
name='user-otp-disable'),
path('first-login/', users_view.UserFirstLoginView.as_view(), name='user-first-login'),
# openid

View File

@ -122,16 +122,16 @@ class UserLoginView(mixins.AuthMixin, FormView):
self.request.session.set_test_cookie()
return self.render_to_response(context)
except (
errors.PasswdTooSimple,
errors.PasswordTooSimple,
errors.PasswordRequireResetError,
errors.PasswdNeedUpdate,
errors.PasswordNeedUpdate,
errors.OTPBindRequiredError
) as e:
return redirect(e.url)
except (
errors.MFAFailedError,
errors.BlockMFAError,
errors.OTPCodeRequiredError,
errors.MFACodeRequiredError,
errors.SMSCodeRequiredError,
errors.UserPhoneNotSet
) as e:
@ -199,7 +199,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
'demo_mode': os.environ.get("DEMO_MODE"),
'auth_methods': self.get_support_auth_methods(),
'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)
return super().get_context_data(**kwargs)
@ -208,7 +208,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
class UserLoginGuardView(mixins.AuthMixin, RedirectView):
redirect_field_name = 'next'
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')
def format_redirect_url(self, url):
@ -229,15 +229,16 @@ class UserLoginGuardView(mixins.AuthMixin, RedirectView):
user = self.check_user_auth_if_need()
self.check_user_mfa_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)
except errors.MFARequiredError:
return self.format_redirect_url(self.login_otp_url)
return self.format_redirect_url(self.login_mfa_url)
except errors.LoginConfirmBaseError:
return self.format_redirect_url(self.login_confirm_url)
except errors.MFAUnsetError as e:
return e.url
except errors.PasswdTooSimple as e:
except errors.PasswordTooSimple as e:
return e.url
else:
self.login_it(user)

View File

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

View File

@ -1,65 +1,2 @@
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
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))
from .endpoint import SMS, BACKENDS
from .utils import SendAndVerifySMSUtil

View File

@ -9,7 +9,7 @@ from Tea.exceptions import TeaException
from common.utils import get_logger
from common.exceptions import JMSException
from . import BaseSMSClient
from .base import BaseSMSClient
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.http_profile import HttpProfile
from . import BaseSMSClient
from .base import BaseSMSClient
logger = get_logger(__file__)

View File

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

View File

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

View File

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

View File

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

View File

@ -1174,6 +1174,14 @@ button.dim:active:before {
.onoffswitch-checkbox:checked + .onoffswitch-label .onoffswitch-switch {
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-container-single .chosen-single {
background: #ffffff;

View File

@ -11,7 +11,6 @@
{% include '_head_css_js.html' %}
<link href="{% static "css/jumpserver.css" %}" rel="stylesheet">
<script src="{% static "js/jumpserver.js" %}"></script>
<style>
.passwordBox {
@ -43,5 +42,6 @@
</div>
</div>
</body>
{% include '_foot_js.html' %}
{% block custom_foot_js %} {% endblock %}
</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">
<title> {{ JMS_TITLE }} </title>
<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' %}" />
<script src="{% static 'js/jquery-3.1.1.min.js' %}"></script>
<script src="{% static "js/plugins/qrcode/qrcode.min.js" %}"></script>
</head>
<body>
<body style="background-color: #f3f3f4">
<header>
<div class="logo">
<a href="{% url 'index' %}">
<img src="{{ LOGO_URL }}" alt="" width="50px" height="50px"/>
</a>
<a href="{% url 'index' %}">{{ JMS_TITLE }}</a>
<span style="font-size: 18px; line-height: 50px">{{ JMS_TITLE }}</span>
</div>
<div>
<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>
</header>
<body>
@ -34,10 +31,11 @@
{% endblock %}
</body>
<footer>
<div class="" style="margin-top: 100px;">
<div style="margin-top: 100px;">
{% include '_copyright.html' %}
</div>
</footer>
{% include '_foot_js.html' %}
</body>
</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.core.cache import cache
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
from django.shortcuts import reverse
from orgs.utils import current_org
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 import fields
from common.const import choices
from common.db.models import TextChoices
from users.exceptions import MFANotEnabled, PhoneNotSet
from ..signals import post_user_change_password
__all__ = ['User', 'UserPasswordHistory', 'MFAType']
__all__ = ['User', 'UserPasswordHistory']
logger = get_logger(__file__)
class MFAType(TextChoices):
OTP = 'otp', _('One-time password')
SMS_CODE = 'sms', _('SMS verify code')
class AuthMixin:
date_password_last_updated: datetime.datetime
history_passwords: models.Manager
need_update_password: bool
public_key: str
is_local: bool
@property
@ -77,7 +72,8 @@ class AuthMixin:
def is_history_password(self, password):
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:
if check_password(password, history_password.password):
@ -474,9 +470,11 @@ class MFAMixin:
@property
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
if settings.SECURITY_MFA_AUTH == 2 and self.is_org_admin:
# 2 管理员强制开启
if force_level == 2 and self.is_org_admin:
return True
return self.mfa_level == 2
@ -489,86 +487,32 @@ class MFAMixin:
def disable_mfa(self):
self.mfa_level = 0
self.otp_secret_key = None
def reset_mfa(self):
if self.mfa_is_otp():
self.otp_secret_key = ''
def no_active_mfa(self):
return len(self.active_mfa_backends) == 0
@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
def mfa_is_otp():
if settings.OTP_IN_RADIUS:
return False
return True
def get_user_mfa_backends(user):
from authentication.mfa import MFA_BACKENDS
backends = [cls(user) for cls in MFA_BACKENDS if cls.global_enabled()]
return backends
def check_radius(self, code):
from authentication.backends.radius import RadiusBackend
backend = RadiusBackend()
user = backend.authenticate(None, username=self.username, password=code)
if user:
return True
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')
def get_mfa_backend_by_type(self, mfa_type):
mfa_mapper = self.active_mfa_backends_mapper
backend = mfa_mapper.get(mfa_type)
if not backend:
return None
return backend
class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):

View File

@ -11,8 +11,6 @@
</h2>
</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 %}
{% endblock %}
</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 %}
{% block small_title %}
{% trans 'Authenticate' %}
{% trans 'Enable OTP' %}
{% endblock %}
{% 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="">
{% csrf_token %}
<div class="form-input">

View File

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

View File

@ -137,7 +137,7 @@ class BlockUtilBase:
times_remainder = int(times_up) - int(times_failed)
return times_remainder
def incr_failed_count(self):
def incr_failed_count(self) -> int:
limit_key = self.limit_key
count = cache.get(limit_key, 0)
count += 1
@ -146,6 +146,7 @@ class BlockUtilBase:
limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT
if count >= limit_count:
cache.set(self.block_key, True, self.key_ttl)
return limit_count - count
def get_failed_count(self):
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):
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 -*-
#
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 ~*~
import time
from django.urls import reverse_lazy, reverse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.generic.base import TemplateView
from django.views.generic.edit import FormView
from django.contrib.auth import logout as auth_logout
from django.conf import settings
from django.http.response import HttpResponseForbidden
from django.http.response import HttpResponseRedirect
from authentication.mixins import AuthMixin
from users.models import User
from common.utils import get_logger, get_object_or_none
from authentication.mfa import MFAOtp, otp_failed_msg
from common.utils import get_logger, FlashMessageUtil
from common.mixins.views import PermissionsMixin
from common.permissions import IsValidUser
from ... import forms
from .password import UserVerifyPasswordView
from ... import forms
from ...utils import (
generate_otp_uri, check_otp_code, get_user_or_pre_auth_user,
is_auth_password_time_valid, is_auth_otp_time_valid
generate_otp_uri, check_otp_code,
get_user_or_pre_auth_user,
)
__all__ = [
'UserOtpEnableStartView',
'UserOtpEnableInstallAppView',
'UserOtpEnableBindView', 'UserOtpSettingsSuccessView',
'UserDisableMFAView', 'UserOtpUpdateView',
'UserOtpEnableBindView',
'UserOtpDisableView',
]
logger = get_logger(__name__)
@ -34,22 +33,8 @@ logger = get_logger(__name__)
class UserOtpEnableStartView(UserVerifyPasswordView):
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):
if settings.OTP_IN_RADIUS:
success_url = reverse_lazy('authentication:user-otp-settings-success')
else:
success_url = reverse('authentication:user-otp-enable-install-app')
return success_url
return reverse('authentication:user-otp-enable-install-app')
class UserOtpEnableInstallAppView(TemplateView):
@ -65,69 +50,68 @@ class UserOtpEnableInstallAppView(TemplateView):
class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
template_name = 'users/user_otp_enable_bind.html'
form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-settings-success')
def get(self, request, *args, **kwargs):
if self._check_can_bind():
return super().get(request, *args, **kwargs)
return HttpResponseForbidden()
pre_response = self._pre_check_can_bind()
if pre_response:
return pre_response
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if self._check_can_bind():
return super().post(request, *args, **kwargs)
return HttpResponseForbidden()
pre_response = self._pre_check_can_bind()
if pre_response:
return pre_response
return super().post(request, *args, **kwargs)
def _check_authenticated_user_can_bind(self):
user = self.request.user
session = self.request.session
def _pre_check_can_bind(self):
try:
user = self.get_user_from_session()
except:
verify_url = reverse('authentication:user-otp-enable-start')
return HttpResponseRedirect(verify_url)
if not user.mfa_enabled:
return is_auth_password_time_valid(session)
if user.otp_secret_key:
return self.has_already_bound_message()
return None
if not user.otp_secret_key:
return is_auth_password_time_valid(session)
return is_auth_otp_time_valid(session)
def _check_unauthenticated_user_can_bind(self):
session_user = None
if not self.request.session.is_empty():
user_id = self.request.session.get('user_id')
session_user = get_object_or_none(User, pk=user_id)
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()
@staticmethod
def has_already_bound_message():
message_data = {
'title': _('Already bound'),
'error': _('MFA already bound, disable first, then bound'),
'interval': 10,
'redirect_url': reverse('authentication:user-otp-disable'),
}
response = FlashMessageUtil.gen_and_redirect_to(message_data)
return response
def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code')
otp_secret_key = self.request.session.get('otp_secret_key', '')
valid = check_otp_code(otp_secret_key, otp_code)
if valid:
self.save_otp(otp_secret_key)
return super().form_valid(form)
else:
error = _("MFA code invalid, or ntp sync server time")
form.add_error("otp_code", error)
if not valid:
form.add_error("otp_code", otp_failed_msg)
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):
user = get_user_or_pre_auth_user(self.request)
user.enable_mfa()
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):
user = get_user_or_pre_auth_user(self.request)
@ -142,70 +126,40 @@ class UserOtpEnableBindView(AuthMixin, TemplateView, FormView):
return super().get_context_data(**kwargs)
class UserDisableMFAView(FormView):
class UserOtpDisableView(PermissionsMixin, FormView):
template_name = 'users/user_verify_mfa.html'
form_class = forms.UserCheckOtpCodeForm
success_url = reverse_lazy('authentication:user-otp-settings-success')
permission_classes = [IsValidUser]
def form_valid(self, form):
user = self.request.user
otp_code = form.cleaned_data.get('otp_code')
otp = MFAOtp(user)
valid = user.check_mfa(otp_code)
if valid:
user.disable_mfa()
user.save()
return super().form_valid(form)
else:
error = _('MFA code invalid, or ntp sync server time')
ok, error = otp.check_code(otp_code)
if not ok:
form.add_error('otp_code', error)
return super().form_invalid(form)
class UserOtpUpdateView(FormView):
template_name = 'users/user_verify_mfa.html'
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'
otp.disable()
auth_logout(self.request)
return super().form_valid(form)
def get_context_data(self, **kwargs):
title, describe = self.get_title_describe()
context = {
'title': title,
'message': describe,
'interval': 1,
context = super().get_context_data(**kwargs)
context.update({
'title': _("Disable OTP")
})
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'),
'auto_redirect': True,
}
kwargs.update(context)
return super().get_context_data(**kwargs)
url = FlashMessageUtil.gen_message_url(message_data)
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.utils.translation import ugettext as _
from django.views.generic.edit import FormView
from authentication.mixins import PasswordEncryptionViewMixin
from authentication.mixins import PasswordEncryptionViewMixin, AuthMixin
from authentication import errors
from common.utils import get_logger
@ -20,24 +21,27 @@ __all__ = ['UserVerifyPasswordView']
logger = get_logger(__name__)
class UserVerifyPasswordView(PasswordEncryptionViewMixin, FormView):
class UserVerifyPasswordView(AuthMixin, FormView):
template_name = 'users/user_password_verify.html'
form_class = forms.UserCheckPasswordForm
def form_valid(self, form):
user = get_user_or_pre_auth_user(self.request)
if user is None:
return redirect('authentication:login')
try:
password = self.get_decrypted_password(username=user.username)
except errors.AuthFailedError as e:
form.add_error("password", _(f"Password invalid") + f'({e.msg})')
return self.form_invalid(form)
user = authenticate(request=self.request, username=user.username, password=password)
if not user:
form.add_error("password", _("Password invalid"))
return self.form_invalid(form)
self.request.session['user_id'] = str(user.id)
self.request.session['auth_password'] = 1
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
self.mark_password_ok(user)
return redirect(self.get_success_url())
def get_success_url(self):