From a5acdb9f607c472308347a4494013b407611ccb5 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Tue, 7 Jun 2022 19:26:07 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E7=BB=9F=E4=B8=80=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E7=94=A8=E6=88=B7api=20(#8324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: feng626 <1304903146@qq.com> --- apps/audits/signal_handlers.py | 1 + apps/authentication/api/__init__.py | 1 + apps/authentication/api/confirm.py | 85 +++++++++++++++++++++ apps/authentication/api/dingtalk.py | 5 +- apps/authentication/api/feishu.py | 4 +- apps/authentication/api/wecom.py | 5 +- apps/authentication/const.py | 8 ++ apps/authentication/serializers/__init__.py | 1 + apps/authentication/serializers/confirm.py | 11 +++ apps/authentication/urls/api_urls.py | 1 + apps/authentication/views/dingtalk.py | 10 +-- apps/authentication/views/feishu.py | 9 +-- apps/authentication/views/wecom.py | 9 +-- apps/users/models/user.py | 5 ++ apps/users/permissions.py | 11 ++- apps/users/utils.py | 13 ++++ 16 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 apps/authentication/api/confirm.py create mode 100644 apps/authentication/serializers/confirm.py diff --git a/apps/audits/signal_handlers.py b/apps/audits/signal_handlers.py index 5177cfca7..86a593c41 100644 --- a/apps/audits/signal_handlers.py +++ b/apps/audits/signal_handlers.py @@ -274,6 +274,7 @@ def on_user_auth_success(sender, user, request, login_type=None, **kwargs): logger.debug('User login success: {}'.format(user.username)) check_different_city_login_if_need(user, request) data = generate_data(user.username, request, login_type=login_type) + request.session['login_time'] = data['datetime'].strftime("%Y-%m-%d %H:%M:%S") data.update({'mfa': int(user.mfa_enabled), 'status': True}) write_login_log(**data) diff --git a/apps/authentication/api/__init__.py b/apps/authentication/api/__init__.py index 01a4c52e9..85fda3c1e 100644 --- a/apps/authentication/api/__init__.py +++ b/apps/authentication/api/__init__.py @@ -5,6 +5,7 @@ from .connection_token import * from .token import * from .mfa import * from .access_key import * +from .confirm import * from .login_confirm import * from .sso import * from .wecom import * diff --git a/apps/authentication/api/confirm.py b/apps/authentication/api/confirm.py new file mode 100644 index 000000000..c77a1d533 --- /dev/null +++ b/apps/authentication/api/confirm.py @@ -0,0 +1,85 @@ +# -*- 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.response import Response + +from common.permissions import IsValidUser +from ..mfa import MFAOtp +from ..const import ConfirmType +from ..mixins import authenticate +from ..serializers import ConfirmSerializer + + +class ConfirmViewSet(ListCreateAPIView): + permission_classes = (IsValidUser,) + serializer_class = ConfirmSerializer + + def check(self, confirm_type: str): + if confirm_type == ConfirmType.MFA: + return bool(MFAOtp(self.user).is_active()) + + if confirm_type == ConfirmType.PASSWORD: + return self.user.is_password_authenticate() + + 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) + + 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') + secret_key = validated_data.get('secret_key') + ok, msg = self.authenticate(confirm_type, secret_key) + if ok: + request.session["MFA_VERIFY_TIME"] = int(time.time()) + return Response('ok') + return Response({'error': msg}, status=400) diff --git a/apps/authentication/api/dingtalk.py b/apps/authentication/api/dingtalk.py index da03f015b..ccc3db64d 100644 --- a/apps/authentication/api/dingtalk.py +++ b/apps/authentication/api/dingtalk.py @@ -2,7 +2,7 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from users.permissions import IsAuthPasswdTimeValid +from users.permissions import IsAuthConfirmTimeValid from users.models import User from common.utils import get_logger from common.mixins.api import RoleUserMixin, RoleAdminMixin @@ -26,9 +26,8 @@ class DingTalkQRUnBindBase(APIView): class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase): - permission_classes = (IsAuthPasswdTimeValid,) + permission_classes = (IsAuthConfirmTimeValid,) class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase): user_id_url_kwarg = 'user_id' - \ No newline at end of file diff --git a/apps/authentication/api/feishu.py b/apps/authentication/api/feishu.py index aaed60db9..13637b247 100644 --- a/apps/authentication/api/feishu.py +++ b/apps/authentication/api/feishu.py @@ -2,7 +2,7 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from users.permissions import IsAuthPasswdTimeValid +from users.permissions import IsAuthConfirmTimeValid from users.models import User from common.utils import get_logger from common.mixins.api import RoleUserMixin, RoleAdminMixin @@ -26,7 +26,7 @@ class FeiShuQRUnBindBase(APIView): class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase): - permission_classes = (IsAuthPasswdTimeValid,) + permission_classes = (IsAuthConfirmTimeValid,) class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase): diff --git a/apps/authentication/api/wecom.py b/apps/authentication/api/wecom.py index 486efde21..e64bc9919 100644 --- a/apps/authentication/api/wecom.py +++ b/apps/authentication/api/wecom.py @@ -2,7 +2,7 @@ from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response -from users.permissions import IsAuthPasswdTimeValid +from users.permissions import IsAuthConfirmTimeValid from users.models import User from common.utils import get_logger from common.mixins.api import RoleUserMixin, RoleAdminMixin @@ -26,9 +26,8 @@ class WeComQRUnBindBase(APIView): class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase): - permission_classes = (IsAuthPasswdTimeValid,) + permission_classes = (IsAuthConfirmTimeValid,) class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase): user_id_url_kwarg = 'user_id' - \ No newline at end of file diff --git a/apps/authentication/const.py b/apps/authentication/const.py index f5cf56471..4c8578dff 100644 --- a/apps/authentication/const.py +++ b/apps/authentication/const.py @@ -1,2 +1,10 @@ +from django.db.models import TextChoices + RSA_PRIVATE_KEY = 'rsa_private_key' RSA_PUBLIC_KEY = 'rsa_public_key' + + +class ConfirmType(TextChoices): + RELOGIN = 'relogin', 'Re-Login' + PASSWORD = 'password', 'Password' + MFA = 'mfa', 'MFA' diff --git a/apps/authentication/serializers/__init__.py b/apps/authentication/serializers/__init__.py index 7697c46db..aa94f0fc8 100644 --- a/apps/authentication/serializers/__init__.py +++ b/apps/authentication/serializers/__init__.py @@ -1,3 +1,4 @@ from .token import * from .connect_token import * from .password_mfa import * +from .confirm import * diff --git a/apps/authentication/serializers/confirm.py b/apps/authentication/serializers/confirm.py new file mode 100644 index 000000000..fe5984190 --- /dev/null +++ b/apps/authentication/serializers/confirm.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + +from common.drf.fields import EncryptedField +from ..const import ConfirmType + + +class ConfirmSerializer(serializers.Serializer): + confirm_type = serializers.ChoiceField( + required=True, choices=ConfirmType.choices + ) + secret_key = EncryptedField() diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index 754b7c793..44a89bf97 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -26,6 +26,7 @@ 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('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'), diff --git a/apps/authentication/views/dingtalk.py b/apps/authentication/views/dingtalk.py index e97424aee..6b93b3d4c 100644 --- a/apps/authentication/views/dingtalk.py +++ b/apps/authentication/views/dingtalk.py @@ -9,8 +9,9 @@ from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.exceptions import APIException from users.views import UserVerifyPasswordView -from users.utils import is_auth_password_time_valid +from users.utils import is_auth_confirm_time_valid from users.models import User +from users.permissions import IsAuthConfirmTimeValid from common.utils import get_logger, FlashMessageUtil from common.utils.random import random_string from common.utils.django import reverse, get_object_or_none @@ -118,17 +119,12 @@ class DingTalkOAuthMixin(DingTalkBaseMixin, View): class DingTalkQRBindView(DingTalkQRMixin, View): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid) def get(self, request: HttpRequest): user = request.user redirect_url = request.GET.get('redirect_url') - if not is_auth_password_time_valid(request.session): - msg = _('Please verify your password first') - response = self.get_failed_response(redirect_url, msg, msg) - return response - redirect_uri = reverse('authentication:dingtalk-qr-bind-callback', kwargs={'user_id': user.id}, external=True) redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) diff --git a/apps/authentication/views/feishu.py b/apps/authentication/views/feishu.py index 172aa2603..ea0db44e1 100644 --- a/apps/authentication/views/feishu.py +++ b/apps/authentication/views/feishu.py @@ -8,7 +8,7 @@ from django.db.utils import IntegrityError from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.exceptions import APIException -from users.utils import is_auth_password_time_valid +from users.permissions import IsAuthConfirmTimeValid from users.views import UserVerifyPasswordView from users.models import User from common.utils import get_logger, FlashMessageUtil @@ -89,17 +89,12 @@ class FeiShuQRMixin(PermissionsMixin, View): class FeiShuQRBindView(FeiShuQRMixin, View): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid) def get(self, request: HttpRequest): user = request.user redirect_url = request.GET.get('redirect_url') - if not is_auth_password_time_valid(request.session): - msg = _('Please verify your password first') - response = self.get_failed_response(redirect_url, msg, msg) - return response - redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True) redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) diff --git a/apps/authentication/views/wecom.py b/apps/authentication/views/wecom.py index 9b7963de8..cb5cc2178 100644 --- a/apps/authentication/views/wecom.py +++ b/apps/authentication/views/wecom.py @@ -9,8 +9,8 @@ from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.exceptions import APIException from users.views import UserVerifyPasswordView -from users.utils import is_auth_password_time_valid from users.models import User +from users.permissions import IsAuthConfirmTimeValid from common.utils import get_logger, FlashMessageUtil from common.utils.random import random_string from common.utils.django import reverse, get_object_or_none @@ -118,17 +118,12 @@ class WeComOAuthMixin(WeComBaseMixin, View): class WeComQRBindView(WeComQRMixin, View): - permission_classes = (IsAuthenticated,) + permission_classes = (IsAuthenticated, IsAuthConfirmTimeValid) def get(self, request: HttpRequest): user = request.user redirect_url = request.GET.get('redirect_url') - if not is_auth_password_time_valid(request.session): - msg = _('Please verify your password first') - response = self.get_failed_response(redirect_url, msg, msg) - return response - redirect_uri = reverse('authentication:wecom-qr-bind-callback', kwargs={'user_id': user.id}, external=True) redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) diff --git a/apps/users/models/user.py b/apps/users/models/user.py index c22be9523..13c5edb0f 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -791,6 +791,11 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): def is_local(self): return self.source == self.Source.local.value + def is_password_authenticate(self): + cas = self.Source.cas + saml2 = self.Source.saml2 + return self.source not in [cas, saml2] + def set_unprovide_attr_if_need(self): if not self.name: self.name = self.username diff --git a/apps/users/permissions.py b/apps/users/permissions.py index 03534d211..a0df71116 100644 --- a/apps/users/permissions.py +++ b/apps/users/permissions.py @@ -1,10 +1,17 @@ from rest_framework import permissions -from .utils import is_auth_password_time_valid +from .utils import is_auth_password_time_valid, is_auth_confirm_time_valid class IsAuthPasswdTimeValid(permissions.IsAuthenticated): def has_permission(self, request, view): return super().has_permission(request, view) \ - and is_auth_password_time_valid(request.session) + and is_auth_password_time_valid(request.session) + + +class IsAuthConfirmTimeValid(permissions.IsAuthenticated): + + def has_permission(self, request, view): + return super().has_permission(request, view) \ + and is_auth_confirm_time_valid(request.session) diff --git a/apps/users/utils.py b/apps/users/utils.py index a2fb9afac..60485f497 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -255,3 +255,16 @@ def is_auth_password_time_valid(session): def is_auth_otp_time_valid(session): return is_auth_time_valid(session, 'auth_otp_expired_at') + + +def is_confirm_time_valid(session, key): + if not settings.SECURITY_VIEW_AUTH_NEED_MFA: + return True + mfa_verify_time = session.get(key, 0) + if time.time() - mfa_verify_time < settings.SECURITY_MFA_VERIFY_TTL: + return True + return False + + +def is_auth_confirm_time_valid(session): + return is_confirm_time_valid(session, 'MFA_VERIFY_TIME')