perf: 优化confirm接口 (#8451)

* perf: 优化confirm接口

* perf: 修改 校验

* perf: 优化 confirm API 逻辑

* Delete django.po

Co-authored-by: feng626 <1304903146@qq.com>
Co-authored-by: ibuler <ibuler@qq.com>
Co-authored-by: Jiangjie.Bai <bugatti_it@163.com>
Co-authored-by: feng626 <57284900+feng626@users.noreply.github.com>
This commit is contained in:
fit2bot
2022-07-04 11:29:39 +08:00
committed by GitHub
parent ca19e45905
commit a6cc8a8b05
28 changed files with 273 additions and 188 deletions

View File

@@ -1,85 +1,57 @@
# -*- coding: utf-8 -*-
#
import time
from datetime import datetime
from django.utils import timezone
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
from rest_framework.generics import ListCreateAPIView
from rest_framework.generics import RetrieveAPIView, CreateAPIView
from rest_framework.response import Response
from rest_framework import status
from common.permissions import IsValidUser
from ..mfa import MFAOtp
from ..const import ConfirmType
from ..mixins import authenticate
from ..serializers import ConfirmSerializer
class ConfirmViewSet(ListCreateAPIView):
class ConfirmApi(RetrieveAPIView, CreateAPIView):
permission_classes = (IsValidUser,)
serializer_class = ConfirmSerializer
def check(self, confirm_type: str):
if confirm_type == ConfirmType.MFA:
return self.user.mfa_enabled
def get_confirm_backend(self, confirm_type):
backend_classes = ConfirmType.get_can_confirm_backend_classes(confirm_type)
if not backend_classes:
return
for backend_cls in backend_classes:
backend = backend_cls(self.request.user, self.request)
if not backend.check():
continue
return backend
if confirm_type == ConfirmType.PASSWORD:
return self.user.is_password_authenticate()
def retrieve(self, request, *args, **kwargs):
confirm_type = request.query_params.get('confirm_type')
backend = self.get_confirm_backend(confirm_type)
if backend is None:
msg = _('This action require verify your MFA')
return Response(data={'error': msg}, status=status.HTTP_404_NOT_FOUND)
if confirm_type == ConfirmType.RELOGIN:
return not self.user.is_password_authenticate()
def authenticate(self, confirm_type, secret_key):
if confirm_type == ConfirmType.MFA:
ok, msg = MFAOtp(self.user).check_code(secret_key)
return ok, msg
if confirm_type == ConfirmType.PASSWORD:
ok = authenticate(self.request, username=self.user.username, password=secret_key)
msg = '' if ok else _('Authentication failed password incorrect')
return ok, msg
if confirm_type == ConfirmType.RELOGIN:
now = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
now = datetime.strptime(now, '%Y-%m-%d %H:%M:%S')
login_time = self.request.session.get('login_time')
SPECIFIED_TIME = 5
msg = _('Login time has exceeded {} minutes, please login again').format(SPECIFIED_TIME)
if not login_time:
return False, msg
login_time = datetime.strptime(login_time, '%Y-%m-%d %H:%M:%S')
if (now - login_time).seconds >= SPECIFIED_TIME * 60:
return False, msg
return True, ''
@property
def user(self):
return self.request.user
def list(self, request, *args, **kwargs):
if not settings.SECURITY_VIEW_AUTH_NEED_MFA:
return Response('ok')
mfa_verify_time = request.session.get('MFA_VERIFY_TIME', 0)
if time.time() - mfa_verify_time < settings.SECURITY_MFA_VERIFY_TTL:
return Response('ok')
data = []
for i, confirm_type in enumerate(ConfirmType.values, 1):
if self.check(confirm_type):
data.append({'name': confirm_type, 'level': i})
msg = _('This action require verify your MFA')
return Response({'error': msg, 'backends': data}, status=400)
data = {
'confirm_type': backend.name,
'content': backend.content,
}
return Response(data=data)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
validated_data = serializer.validated_data
confirm_type = validated_data.get('confirm_type')
mfa_type = validated_data.get('mfa_type')
secret_key = validated_data.get('secret_key')
ok, msg = self.authenticate(confirm_type, secret_key)
backend = self.get_confirm_backend(confirm_type)
ok, msg = backend.authenticate(secret_key, mfa_type)
if ok:
request.session["MFA_VERIFY_TIME"] = int(time.time())
request.session['CONFIRM_LEVEL'] = ConfirmType.values.index(confirm_type) + 1
request.session['CONFIRM_TIME'] = int(time.time())
return Response('ok')
return Response({'error': msg}, status=400)

View File

@@ -2,10 +2,11 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from users.permissions import IsAuthConfirmTimeValid
from users.models import User
from common.utils import get_logger
from common.permissions import UserConfirmation
from common.mixins.api import RoleUserMixin, RoleAdminMixin
from authentication.const import ConfirmType
from authentication import errors
logger = get_logger(__file__)
@@ -26,7 +27,7 @@ class DingTalkQRUnBindBase(APIView):
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
permission_classes = (IsAuthConfirmTimeValid,)
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):

View File

@@ -2,10 +2,11 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from users.permissions import IsAuthConfirmTimeValid
from users.models import User
from common.utils import get_logger
from common.permissions import UserConfirmation
from common.mixins.api import RoleUserMixin, RoleAdminMixin
from authentication.const import ConfirmType
from authentication import errors
logger = get_logger(__file__)
@@ -26,7 +27,7 @@ class FeiShuQRUnBindBase(APIView):
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
permission_classes = (IsAuthConfirmTimeValid,)
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):

View File

@@ -10,22 +10,17 @@ from rest_framework.generics import CreateAPIView
from rest_framework.serializers import ValidationError
from rest_framework.response import Response
from common.permissions import IsValidUser, NeedMFAVerify
from common.utils import get_logger
from common.exceptions import UnexpectError
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
logger = get_logger(__name__)
__all__ = [
'MFAChallengeVerifyApi', 'UserOtpVerifyApi',
'MFASendCodeApi'
'MFAChallengeVerifyApi', 'MFASendCodeApi'
]
@@ -88,30 +83,3 @@ class MFAChallengeVerifyApi(AuthMixin, CreateAPIView):
raise ValidationError(data)
except errors.NeedMoreInfoError as e:
return Response(e.as_data(), status=200)
class UserOtpVerifyApi(CreateAPIView):
permission_classes = (IsValidUser,)
serializer_class = OtpVerifySerializer
def get(self, request, *args, **kwargs):
return Response({'code': 'valid', 'msg': 'verified'})
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
code = serializer.validated_data["code"]
otp = MFAOtp(request.user)
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, {}").format(error)}, status=400)
def get_permissions(self):
if self.request.method.lower() == 'get' \
and settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [NeedMFAVerify]
return super().get_permissions()

View File

@@ -2,10 +2,11 @@ from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from users.permissions import IsAuthConfirmTimeValid
from users.models import User
from common.utils import get_logger
from common.permissions import UserConfirmation
from common.mixins.api import RoleUserMixin, RoleAdminMixin
from authentication.const import ConfirmType
from authentication import errors
logger = get_logger(__file__)
@@ -26,7 +27,7 @@ class WeComQRUnBindBase(APIView):
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
permission_classes = (IsAuthConfirmTimeValid,)
permission_classes = (UserConfirmation.require(ConfirmType.ReLogin),)
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):

View File

@@ -0,0 +1,5 @@
from .mfa import ConfirmMFA
from .password import ConfirmPassword
from .relogin import ConfirmReLogin
CONFIRM_BACKENDS = [ConfirmReLogin, ConfirmPassword, ConfirmMFA]

View File

@@ -0,0 +1,30 @@
import abc
class BaseConfirm(abc.ABC):
def __init__(self, user, request):
self.user = user
self.request = request
@property
@abc.abstractmethod
def name(self) -> str:
return ''
@property
@abc.abstractmethod
def display_name(self) -> str:
return ''
@abc.abstractmethod
def check(self) -> bool:
return False
@property
def content(self):
return ''
@abc.abstractmethod
def authenticate(self, secret_key, mfa_type) -> tuple:
return False, 'Error msg'

View File

@@ -0,0 +1,26 @@
from users.models import User
from .base import BaseConfirm
class ConfirmMFA(BaseConfirm):
name = 'mfa'
display_name = 'MFA'
def check(self):
return self.user.active_mfa_backends
@property
def content(self):
backends = User.get_user_mfa_backends(self.user)
return [{
'name': backend.name,
'disabled': not bool(backend.is_active()),
'display_name': backend.display_name,
'placeholder': backend.placeholder,
} for backend in backends]
def authenticate(self, secret_key, mfa_type):
mfa_backend = self.user.get_mfa_backend_by_type(mfa_type)
ok, msg = mfa_backend.check_code(secret_key)
return ok, msg

View File

@@ -0,0 +1,17 @@
from django.utils.translation import ugettext_lazy as _
from authentication.mixins import authenticate
from .base import BaseConfirm
class ConfirmPassword(BaseConfirm):
name = 'password'
display_name = _('Password')
def check(self):
return self.user.is_password_authenticate()
def authenticate(self, secret_key, mfa_type):
ok = authenticate(self.request, username=self.user.username, password=secret_key)
msg = '' if ok else _('Authentication failed password incorrect')
return ok, msg

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from .base import BaseConfirm
SPECIFIED_TIME = 5
RELOGIN_ERROR = _('Login time has exceeded {} minutes, please login again').format(SPECIFIED_TIME)
class ConfirmReLogin(BaseConfirm):
name = 'relogin'
display_name = 'Re-Login'
def check(self):
return not self.user.is_password_authenticate()
def authenticate(self, secret_key, mfa_type):
now = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
now = datetime.strptime(now, '%Y-%m-%d %H:%M:%S')
login_time = self.request.session.get('login_time')
msg = RELOGIN_ERROR
if not login_time:
return False, msg
login_time = datetime.strptime(login_time, '%Y-%m-%d %H:%M:%S')
if (now - login_time).seconds >= SPECIFIED_TIME * 60:
return False, msg
return True, ''

View File

@@ -1,10 +1,35 @@
from django.db.models import TextChoices
from authentication.confirm import CONFIRM_BACKENDS
from .confirm import ConfirmMFA, ConfirmPassword, ConfirmReLogin
from .mfa import MFAOtp, MFASms, MFARadius
RSA_PRIVATE_KEY = 'rsa_private_key'
RSA_PUBLIC_KEY = 'rsa_public_key'
CONFIRM_BACKEND_MAP = {backend.name: backend for backend in CONFIRM_BACKENDS}
class ConfirmType(TextChoices):
RELOGIN = 'relogin', 'Re-Login'
PASSWORD = 'password', 'Password'
MFA = 'mfa', 'MFA'
ReLogin = ConfirmReLogin.name, ConfirmReLogin.display_name
PASSWORD = ConfirmPassword.name, ConfirmPassword.display_name
MFA = ConfirmMFA.name, ConfirmMFA.display_name
@classmethod
def get_can_confirm_types(cls, confirm_type):
start = cls.values.index(confirm_type)
return cls.values[start:]
@classmethod
def get_can_confirm_backend_classes(cls, confirm_type):
types = cls.get_can_confirm_types(confirm_type)
backend_classes = [
CONFIRM_BACKEND_MAP[tp] for tp in types if tp in CONFIRM_BACKEND_MAP
]
return backend_classes
class MFAType(TextChoices):
OTP = MFAOtp.name, MFAOtp.display_name
SMS = MFASms.name, MFASms.display_name
Radius = MFARadius.name, MFARadius.display_name

View File

@@ -1,11 +1,10 @@
from rest_framework import serializers
from common.drf.fields import EncryptedField
from ..const import ConfirmType
from ..const import ConfirmType, MFAType
class ConfirmSerializer(serializers.Serializer):
confirm_type = serializers.ChoiceField(
required=True, choices=ConfirmType.choices
)
confirm_type = serializers.ChoiceField(required=True, allow_blank=True, choices=ConfirmType.choices)
mfa_type = serializers.ChoiceField(required=False, allow_blank=True, choices=MFAType.choices)
secret_key = EncryptedField()

View File

@@ -4,9 +4,8 @@ from rest_framework import serializers
from common.drf.fields import EncryptedField
__all__ = [
'OtpVerifySerializer', 'MFAChallengeSerializer', 'MFASelectTypeSerializer',
'MFAChallengeSerializer', 'MFASelectTypeSerializer',
'PasswordVerifySerializer',
]
@@ -29,7 +28,3 @@ class MFAChallengeSerializer(serializers.Serializer):
def update(self, instance, validated_data):
pass
class OtpVerifySerializer(serializers.Serializer):
code = serializers.CharField(max_length=6, min_length=6)

View File

@@ -26,13 +26,12 @@ urlpatterns = [
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('confirm/', api.ConfirmViewSet.as_view(), name='user-confirm'),
path('confirm/', api.ConfirmApi.as_view(), name='user-confirm'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
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('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

@@ -8,17 +8,17 @@ from django.db.utils import IntegrityError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.exceptions import APIException
from users.views import UserVerifyPasswordView
from users.utils import is_auth_confirm_time_valid
from users.models import User
from users.permissions import IsAuthConfirmTimeValid
from users.views import UserVerifyPasswordView
from common.utils import get_logger, FlashMessageUtil
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.sdk.im.dingtalk import URL
from common.mixins.views import PermissionsMixin
from common.mixins.views import UserConfirmRequiredExceptionMixin, PermissionsMixin
from common.permissions import UserConfirmation
from authentication import errors
from authentication.mixins import AuthMixin
from authentication.const import ConfirmType
from common.sdk.im.dingtalk import DingTalk
from common.utils.common import get_request_ip
from authentication.notifications import OAuthBindMessage
@@ -30,7 +30,7 @@ logger = get_logger(__file__)
DINGTALK_STATE_SESSION_KEY = '_dingtalk_state'
class DingTalkBaseMixin(PermissionsMixin, View):
class DingTalkBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, View):
def dispatch(self, request, *args, **kwargs):
try:
return super().dispatch(request, *args, **kwargs)
@@ -119,7 +119,7 @@ class DingTalkOAuthMixin(DingTalkBaseMixin, View):
class DingTalkQRBindView(DingTalkQRMixin, View):
permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid)
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
def get(self, request: HttpRequest):
user = request.user

View File

@@ -8,16 +8,17 @@ from django.db.utils import IntegrityError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.exceptions import APIException
from users.permissions import IsAuthConfirmTimeValid
from users.views import UserVerifyPasswordView
from users.models import User
from users.views import UserVerifyPasswordView
from common.utils import get_logger, FlashMessageUtil
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.mixins.views import PermissionsMixin
from common.mixins.views import UserConfirmRequiredExceptionMixin, PermissionsMixin
from common.permissions import UserConfirmation
from common.sdk.im.feishu import FeiShu, URL
from common.utils.common import get_request_ip
from authentication import errors
from authentication.const import ConfirmType
from authentication.mixins import AuthMixin
from authentication.notifications import OAuthBindMessage
@@ -27,7 +28,7 @@ logger = get_logger(__file__)
FEISHU_STATE_SESSION_KEY = '_feishu_state'
class FeiShuQRMixin(PermissionsMixin, View):
class FeiShuQRMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, View):
def dispatch(self, request, *args, **kwargs):
try:
return super().dispatch(request, *args, **kwargs)
@@ -89,7 +90,7 @@ class FeiShuQRMixin(PermissionsMixin, View):
class FeiShuQRBindView(FeiShuQRMixin, View):
permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid)
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
def get(self, request: HttpRequest):
user = request.user

View File

@@ -8,18 +8,19 @@ from django.db.utils import IntegrityError
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.exceptions import APIException
from users.views import UserVerifyPasswordView
from users.models import User
from users.permissions import IsAuthConfirmTimeValid
from users.views import UserVerifyPasswordView
from common.utils import get_logger, FlashMessageUtil
from common.utils.random import random_string
from common.utils.django import reverse, get_object_or_none
from common.sdk.im.wecom import URL
from common.sdk.im.wecom import WeCom
from common.mixins.views import PermissionsMixin
from common.mixins.views import UserConfirmRequiredExceptionMixin, PermissionsMixin
from common.utils.common import get_request_ip
from common.permissions import UserConfirmation
from authentication import errors
from authentication.mixins import AuthMixin
from authentication.const import ConfirmType
from authentication.notifications import OAuthBindMessage
from .mixins import METAMixin
@@ -29,7 +30,7 @@ logger = get_logger(__file__)
WECOM_STATE_SESSION_KEY = '_wecom_state'
class WeComBaseMixin(PermissionsMixin, View):
class WeComBaseMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, View):
def dispatch(self, request, *args, **kwargs):
try:
return super().dispatch(request, *args, **kwargs)
@@ -118,7 +119,7 @@ class WeComOAuthMixin(WeComBaseMixin, View):
class WeComQRBindView(WeComQRMixin, View):
permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid)
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.ReLogin))
def get(self, request: HttpRequest):
user = request.user