pref: 优化MFA (#7153)

* perf: 优化mfa 和登录

* perf: stash

* stash

* pref: 基本完成

* perf: remove init function

* perf: 优化命名

* perf: 优化backends

* perf: 基本完成优化

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

Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com>
This commit is contained in:
fit2bot
2021-11-10 11:30:48 +08:00
committed by GitHub
parent bac974b4f2
commit 17303c0550
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

@@ -1,98 +0,0 @@
import random
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from common.sdk.sms import SMS
from common.utils import get_logger
from common.exceptions import JMSException
logger = get_logger(__file__)
class CodeExpired(JMSException):
default_code = 'verify_code_expired'
default_detail = _('The verification code has expired. Please resend it')
class CodeError(JMSException):
default_code = 'verify_code_error'
default_detail = _('The verification code is incorrect')
class CodeSendTooFrequently(JMSException):
default_code = 'code_send_too_frequently'
default_detail = _('Please wait {} seconds before sending')
def __init__(self, ttl):
super().__init__(detail=self.default_detail.format(ttl))
class VerifyCodeUtil:
KEY_TMPL = 'auth-verify_code-{}'
TIMEOUT = 60
def __init__(self, account, key_suffix=None, timeout=None):
self.account = account
self.key_suffix = key_suffix
self.code = ''
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):
"""
生成,保存,发送
"""
ttl = self.ttl()
if ttl > 0:
raise CodeSendTooFrequently(ttl)
try:
self.generate()
self.save()
self.send()
except JMSException:
self.clear()
raise
def generate(self):
code = ''.join(random.sample('0123456789', 4))
self.code = code
return code
def clear(self):
cache.delete(self.key)
def save(self):
cache.set(self.key, self.code, self.timeout)
def send(self):
"""
发送信息的方法,如果有错误直接抛出 api 异常
"""
account = self.account
code = self.code
sms = SMS()
sms.send_verify_code(account, code)
logger.info(f'Send sms verify code: account={account} code={code}')
def verify(self, code):
right = cache.get(self.key)
if not right:
raise CodeExpired
if right != code:
raise CodeError
self.clear()
return True
def ttl(self):
return cache.ttl(self.key)
def get_code(self):
return cache.get(self.key)

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