mirror of https://github.com/jumpserver/jumpserver
feat: 添加 飞书 (#6602)
* feat: 添加 飞书 Co-authored-by: xinwen <coderWen@126.com> Co-authored-by: wenyann <64353056+wenyann@users.noreply.github.com>pull/6633/head
parent
a2907a6e6d
commit
54751a715c
|
@ -9,4 +9,5 @@ from .login_confirm import *
|
||||||
from .sso import *
|
from .sso import *
|
||||||
from .wecom import *
|
from .wecom import *
|
||||||
from .dingtalk import *
|
from .dingtalk import *
|
||||||
|
from .feishu import *
|
||||||
from .password import *
|
from .password import *
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
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.models import User
|
||||||
|
from common.utils import get_logger
|
||||||
|
from common.permissions import IsOrgAdmin
|
||||||
|
from common.mixins.api import RoleUserMixin, RoleAdminMixin
|
||||||
|
from authentication import errors
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRUnBindBase(APIView):
|
||||||
|
user: User
|
||||||
|
|
||||||
|
def post(self, request: Request, **kwargs):
|
||||||
|
user = self.user
|
||||||
|
|
||||||
|
if not user.feishu_id:
|
||||||
|
raise errors.FeiShuNotBound
|
||||||
|
|
||||||
|
user.feishu_id = None
|
||||||
|
user.save()
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
|
||||||
|
permission_classes = (IsAuthPasswdTimeValid,)
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
|
||||||
|
user_id_url_kwarg = 'user_id'
|
||||||
|
permission_classes = (IsOrgAdmin,)
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuEventSubscriptionCallback(APIView):
|
||||||
|
"""
|
||||||
|
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
|
||||||
|
"""
|
||||||
|
permission_classes = ()
|
||||||
|
|
||||||
|
def post(self, request: Request, *args, **kwargs):
|
||||||
|
return Response(data=request.data)
|
|
@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuAuthentication(JMSModelBackend):
|
||||||
|
"""
|
||||||
|
什么也不做呀😺
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
class AuthorizationTokenAuthentication(JMSModelBackend):
|
||||||
"""
|
"""
|
||||||
什么也不做呀😺
|
什么也不做呀😺
|
||||||
|
|
|
@ -315,6 +315,11 @@ class DingTalkNotBound(JMSException):
|
||||||
default_detail = 'DingTalk is not bound'
|
default_detail = 'DingTalk is not bound'
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuNotBound(JMSException):
|
||||||
|
default_code = 'feishu_not_bound'
|
||||||
|
default_detail = 'FeiShu is not bound'
|
||||||
|
|
||||||
|
|
||||||
class PasswdInvalid(JMSException):
|
class PasswdInvalid(JMSException):
|
||||||
default_code = 'passwd_invalid'
|
default_code = 'passwd_invalid'
|
||||||
default_detail = _('Your password is invalid')
|
default_detail = _('Your password is invalid')
|
||||||
|
|
|
@ -191,7 +191,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %}
|
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
|
||||||
<div class="hr-line-dashed"></div>
|
<div class="hr-line-dashed"></div>
|
||||||
<div style="display: inline-block; float: left">
|
<div style="display: inline-block; float: left">
|
||||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||||
|
@ -215,6 +215,11 @@
|
||||||
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
|
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if AUTH_FEISHU %}
|
||||||
|
<a href="{% url 'authentication:feishu-qr-login' %}" class="more-login-item">
|
||||||
|
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
@ -20,6 +20,10 @@ urlpatterns = [
|
||||||
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
|
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
|
||||||
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
|
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
|
||||||
|
|
||||||
|
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
|
||||||
|
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'),
|
||||||
|
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
|
||||||
|
|
||||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
||||||
|
|
|
@ -37,6 +37,14 @@ urlpatterns = [
|
||||||
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
|
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
|
||||||
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
|
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
|
||||||
|
|
||||||
|
path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'),
|
||||||
|
path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'),
|
||||||
|
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
|
||||||
|
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
|
||||||
|
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),
|
||||||
|
path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'),
|
||||||
|
path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'),
|
||||||
|
|
||||||
# Profile
|
# Profile
|
||||||
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
|
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
|
||||||
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
|
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
|
||||||
|
|
|
@ -4,3 +4,4 @@ from .login import *
|
||||||
from .mfa import *
|
from .mfa import *
|
||||||
from .wecom import *
|
from .wecom import *
|
||||||
from .dingtalk import *
|
from .dingtalk import *
|
||||||
|
from .feishu import *
|
||||||
|
|
|
@ -0,0 +1,253 @@
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
from django.http.response import HttpResponseRedirect, HttpResponse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from django.views.decorators.cache import never_cache
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from django.views import View
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http.request import HttpRequest
|
||||||
|
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.views import UserVerifyPasswordView
|
||||||
|
from users.models import User
|
||||||
|
from common.utils import get_logger
|
||||||
|
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.message.backends.feishu import FeiShu, URL
|
||||||
|
from authentication import errors
|
||||||
|
from authentication.mixins import AuthMixin
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
FEISHU_STATE_SESSION_KEY = '_feishu_state'
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRMixin(PermissionsMixin, View):
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
except APIException as e:
|
||||||
|
msg = str(e.detail)
|
||||||
|
return self.get_failed_reponse(
|
||||||
|
'/',
|
||||||
|
_('FeiShu Error'),
|
||||||
|
msg
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_state(self):
|
||||||
|
state = self.request.GET.get('state')
|
||||||
|
session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY)
|
||||||
|
if state != session_state:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_verify_state_failed_response(self, redirect_uri):
|
||||||
|
msg = _("You've been hacked")
|
||||||
|
return self.get_failed_reponse(redirect_uri, msg, msg)
|
||||||
|
|
||||||
|
def get_qr_url(self, redirect_uri):
|
||||||
|
state = random_string(16)
|
||||||
|
self.request.session[FEISHU_STATE_SESSION_KEY] = state
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'app_id': settings.FEISHU_APP_ID,
|
||||||
|
'state': state,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
|
}
|
||||||
|
url = URL.AUTHEN + '?' + urllib.parse.urlencode(params)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def get_success_reponse(self, redirect_url, title, msg):
|
||||||
|
ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg')
|
||||||
|
ok_flash_msg_url += '?' + urllib.parse.urlencode({
|
||||||
|
'redirect_url': redirect_url,
|
||||||
|
'title': title,
|
||||||
|
'msg': msg
|
||||||
|
})
|
||||||
|
return HttpResponseRedirect(ok_flash_msg_url)
|
||||||
|
|
||||||
|
def get_failed_reponse(self, redirect_url, title, msg):
|
||||||
|
failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg')
|
||||||
|
failed_flash_msg_url += '?' + urllib.parse.urlencode({
|
||||||
|
'redirect_url': redirect_url,
|
||||||
|
'title': title,
|
||||||
|
'msg': msg
|
||||||
|
})
|
||||||
|
return HttpResponseRedirect(failed_flash_msg_url)
|
||||||
|
|
||||||
|
def get_already_bound_response(self, redirect_url):
|
||||||
|
msg = _('FeiShu is already bound')
|
||||||
|
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRBindView(FeiShuQRMixin, View):
|
||||||
|
permission_classes = (IsAuthenticated,)
|
||||||
|
|
||||||
|
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_reponse(redirect_url, msg, msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True)
|
||||||
|
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||||
|
|
||||||
|
url = self.get_qr_url(redirect_uri)
|
||||||
|
return HttpResponseRedirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
|
||||||
|
permission_classes = (IsAuthenticated,)
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest):
|
||||||
|
code = request.GET.get('code')
|
||||||
|
redirect_url = request.GET.get('redirect_url')
|
||||||
|
|
||||||
|
if not self.verify_state():
|
||||||
|
return self.get_verify_state_failed_response(redirect_url)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if user.feishu_id:
|
||||||
|
response = self.get_already_bound_response(redirect_url)
|
||||||
|
return response
|
||||||
|
|
||||||
|
feishu = FeiShu(
|
||||||
|
app_id=settings.FEISHU_APP_ID,
|
||||||
|
app_secret=settings.FEISHU_APP_SECRET
|
||||||
|
)
|
||||||
|
user_id = feishu.get_user_id_by_code(code)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
msg = _('FeiShu query user failed')
|
||||||
|
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
user.feishu_id = user_id
|
||||||
|
user.save()
|
||||||
|
except IntegrityError as e:
|
||||||
|
if e.args[0] == 1062:
|
||||||
|
msg = _('The FeiShu is already bound to another user')
|
||||||
|
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||||
|
return response
|
||||||
|
raise e
|
||||||
|
|
||||||
|
msg = _('Binding FeiShu successfully')
|
||||||
|
response = self.get_success_reponse(redirect_url, msg, msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuEnableStartView(UserVerifyPasswordView):
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
referer = self.request.META.get('HTTP_REFERER')
|
||||||
|
redirect_url = self.request.GET.get("redirect_url")
|
||||||
|
|
||||||
|
success_url = reverse('authentication:feishu-qr-bind')
|
||||||
|
|
||||||
|
success_url += '?' + urllib.parse.urlencode({
|
||||||
|
'redirect_url': redirect_url or referer
|
||||||
|
})
|
||||||
|
|
||||||
|
return success_url
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRLoginView(FeiShuQRMixin, View):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest):
|
||||||
|
redirect_url = request.GET.get('redirect_url')
|
||||||
|
|
||||||
|
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True)
|
||||||
|
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||||
|
|
||||||
|
url = self.get_qr_url(redirect_uri)
|
||||||
|
return HttpResponseRedirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
|
||||||
|
def get(self, request: HttpRequest):
|
||||||
|
code = request.GET.get('code')
|
||||||
|
redirect_url = request.GET.get('redirect_url')
|
||||||
|
login_url = reverse('authentication:login')
|
||||||
|
|
||||||
|
if not self.verify_state():
|
||||||
|
return self.get_verify_state_failed_response(redirect_url)
|
||||||
|
|
||||||
|
feishu = FeiShu(
|
||||||
|
app_id=settings.FEISHU_APP_ID,
|
||||||
|
app_secret=settings.FEISHU_APP_SECRET
|
||||||
|
)
|
||||||
|
user_id = feishu.get_user_id_by_code(code)
|
||||||
|
if not user_id:
|
||||||
|
# 正常流程不会出这个错误,hack 行为
|
||||||
|
msg = _('Failed to get user from FeiShu')
|
||||||
|
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
user = get_object_or_none(User, feishu_id=user_id)
|
||||||
|
if user is None:
|
||||||
|
title = _('FeiShu is not bound')
|
||||||
|
msg = _('Please login with a password and then bind the WeCom')
|
||||||
|
response = self.get_failed_reponse(login_url, title=title, msg=msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.check_oauth2_auth(user, settings.AUTH_BACKEND_FEISHU)
|
||||||
|
except errors.AuthFailedError as e:
|
||||||
|
self.set_login_failed_mark()
|
||||||
|
msg = e.msg
|
||||||
|
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return self.redirect_to_guard_view()
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(never_cache, name='dispatch')
|
||||||
|
class FlashFeiShuBindSucceedMsgView(TemplateView):
|
||||||
|
template_name = 'flash_message_standalone.html'
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
title = request.GET.get('title')
|
||||||
|
msg = request.GET.get('msg')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': title or _('Binding FeiShu successfully'),
|
||||||
|
'messages': msg or _('Binding FeiShu successfully'),
|
||||||
|
'interval': 5,
|
||||||
|
'redirect_url': request.GET.get('redirect_url'),
|
||||||
|
'auto_redirect': True,
|
||||||
|
}
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(never_cache, name='dispatch')
|
||||||
|
class FlashFeiShuBindFailedMsgView(TemplateView):
|
||||||
|
template_name = 'flash_message_standalone.html'
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
title = request.GET.get('title')
|
||||||
|
msg = request.GET.get('msg')
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'title': title or _('Binding FeiShu failed'),
|
||||||
|
'messages': msg or _('Binding FeiShu failed'),
|
||||||
|
'interval': 5,
|
||||||
|
'redirect_url': request.GET.get('redirect_url'),
|
||||||
|
'auto_redirect': True,
|
||||||
|
}
|
||||||
|
return self.render_to_response(context)
|
|
@ -154,6 +154,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
'AUTH_CAS': settings.AUTH_CAS,
|
'AUTH_CAS': settings.AUTH_CAS,
|
||||||
'AUTH_WECOM': settings.AUTH_WECOM,
|
'AUTH_WECOM': settings.AUTH_WECOM,
|
||||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
||||||
|
'AUTH_FEISHU': settings.AUTH_FEISHU,
|
||||||
'rsa_public_key': rsa_public_key,
|
'rsa_public_key': rsa_public_key,
|
||||||
'forgot_password_url': forgot_password_url
|
'forgot_password_url': forgot_password_url
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,7 @@ import time
|
||||||
import hmac
|
import hmac
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
from common.message.backends.utils import request
|
from common.message.backends.utils import digest, as_request
|
||||||
from common.message.backends.utils import digest
|
|
||||||
from common.message.backends.mixin import BaseRequest
|
from common.message.backends.mixin import BaseRequest
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,7 +33,7 @@ class URL:
|
||||||
|
|
||||||
|
|
||||||
class DingTalkRequests(BaseRequest):
|
class DingTalkRequests(BaseRequest):
|
||||||
invalid_token_errcode = ErrorCode.INVALID_TOKEN
|
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
|
||||||
|
|
||||||
def __init__(self, appid, appsecret, agentid, timeout=None):
|
def __init__(self, appid, appsecret, agentid, timeout=None):
|
||||||
self._appid = appid
|
self._appid = appid
|
||||||
|
@ -55,21 +54,33 @@ class DingTalkRequests(BaseRequest):
|
||||||
expires_in = data['expires_in']
|
expires_in = data['expires_in']
|
||||||
return access_token, expires_in
|
return access_token, expires_in
|
||||||
|
|
||||||
@request
|
def add_token(self, kwargs: dict):
|
||||||
|
params = kwargs.get('params')
|
||||||
|
if params is None:
|
||||||
|
params = {}
|
||||||
|
kwargs['params'] = params
|
||||||
|
params['access_token'] = self.access_token
|
||||||
|
|
||||||
def get(self, url, params=None,
|
def get(self, url, params=None,
|
||||||
with_token=False, with_sign=False,
|
with_token=False, with_sign=False,
|
||||||
check_errcode_is_0=True,
|
check_errcode_is_0=True,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
pass
|
pass
|
||||||
|
get = as_request(get)
|
||||||
|
|
||||||
@request
|
|
||||||
def post(self, url, json=None, params=None,
|
def post(self, url, json=None, params=None,
|
||||||
with_token=False, with_sign=False,
|
with_token=False, with_sign=False,
|
||||||
check_errcode_is_0=True,
|
check_errcode_is_0=True,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
pass
|
pass
|
||||||
|
post = as_request(post)
|
||||||
|
|
||||||
|
def _add_sign(self, kwargs: dict):
|
||||||
|
params = kwargs.get('params')
|
||||||
|
if params is None:
|
||||||
|
params = {}
|
||||||
|
kwargs['params'] = params
|
||||||
|
|
||||||
def _add_sign(self, params: dict):
|
|
||||||
timestamp = str(int(time.time() * 1000))
|
timestamp = str(int(time.time() * 1000))
|
||||||
signature = sign(self._appsecret, timestamp)
|
signature = sign(self._appsecret, timestamp)
|
||||||
accessKey = self._appid
|
accessKey = self._appid
|
||||||
|
@ -78,23 +89,17 @@ class DingTalkRequests(BaseRequest):
|
||||||
params['signature'] = signature
|
params['signature'] = signature
|
||||||
params['accessKey'] = accessKey
|
params['accessKey'] = accessKey
|
||||||
|
|
||||||
def request(self, method, url, params=None,
|
def request(self, method, url,
|
||||||
with_token=False, with_sign=False,
|
with_token=False, with_sign=False,
|
||||||
check_errcode_is_0=True,
|
check_errcode_is_0=True,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
if not isinstance(params, dict):
|
|
||||||
params = {}
|
|
||||||
|
|
||||||
if with_token:
|
|
||||||
params['access_token'] = self.access_token
|
|
||||||
|
|
||||||
if with_sign:
|
if with_sign:
|
||||||
self._add_sign(params)
|
self._add_sign(kwargs)
|
||||||
|
|
||||||
data = self.raw_request(method, url, params=params, **kwargs)
|
|
||||||
if check_errcode_is_0:
|
|
||||||
self.check_errcode_is_0(data)
|
|
||||||
|
|
||||||
|
data = super().request(
|
||||||
|
method, url, with_token=with_token,
|
||||||
|
check_errcode_is_0=check_errcode_is_0, **kwargs)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
|
from common.utils.common import get_logger
|
||||||
|
from common.message.backends.utils import digest
|
||||||
|
from common.message.backends.mixin import RequestMixin, BaseRequest
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class URL:
|
||||||
|
AUTHEN = 'https://open.feishu.cn/open-apis/authen/v1/index'
|
||||||
|
|
||||||
|
GET_TOKEN = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/'
|
||||||
|
|
||||||
|
# https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN
|
||||||
|
GET_USER_INFO_BY_CODE = 'https://open.feishu.cn/open-apis/authen/v1/access_token'
|
||||||
|
|
||||||
|
SEND_MESSAGE = 'https://open.feishu.cn/open-apis/im/v1/messages'
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode:
|
||||||
|
INVALID_APP_ACCESS_TOKEN = 99991664
|
||||||
|
INVALID_USER_ACCESS_TOKEN = 99991668
|
||||||
|
INVALID_TENANT_ACCESS_TOKEN = 99991663
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuRequests(BaseRequest):
|
||||||
|
"""
|
||||||
|
处理系统级错误,抛出 API 异常,直接生成 HTTP 响应,业务代码无需关心这些错误
|
||||||
|
- 确保 status_code == 200
|
||||||
|
- 确保 access_token 无效时重试
|
||||||
|
"""
|
||||||
|
invalid_token_errcodes = (
|
||||||
|
ErrorCode.INVALID_USER_ACCESS_TOKEN, ErrorCode.INVALID_TENANT_ACCESS_TOKEN,
|
||||||
|
ErrorCode.INVALID_APP_ACCESS_TOKEN
|
||||||
|
)
|
||||||
|
code_key = 'code'
|
||||||
|
msg_key = 'msg'
|
||||||
|
|
||||||
|
def __init__(self, app_id, app_secret, timeout=None):
|
||||||
|
self._app_id = app_id
|
||||||
|
self._app_secret = app_secret
|
||||||
|
|
||||||
|
super().__init__(timeout=timeout)
|
||||||
|
|
||||||
|
def get_access_token_cache_key(self):
|
||||||
|
return digest(self._app_id, self._app_secret)
|
||||||
|
|
||||||
|
def request_access_token(self):
|
||||||
|
data = {'app_id': self._app_id, 'app_secret': self._app_secret}
|
||||||
|
response = self.raw_request('post', url=URL.GET_TOKEN, data=data)
|
||||||
|
self.check_errcode_is_0(response)
|
||||||
|
|
||||||
|
access_token = response['tenant_access_token']
|
||||||
|
expires_in = response['expire']
|
||||||
|
return access_token, expires_in
|
||||||
|
|
||||||
|
def add_token(self, kwargs: dict):
|
||||||
|
headers = kwargs.setdefault('headers', {})
|
||||||
|
headers['Authorization'] = f'Bearer {self.access_token}'
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShu(RequestMixin):
|
||||||
|
"""
|
||||||
|
非业务数据导致的错误直接抛异常,说明是系统配置错误,业务代码不用理会
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app_id, app_secret, timeout=None):
|
||||||
|
self._app_id = app_id
|
||||||
|
self._app_secret = app_secret
|
||||||
|
|
||||||
|
self._requests = FeishuRequests(
|
||||||
|
app_id=app_id,
|
||||||
|
app_secret=app_secret,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_id_by_code(self, code):
|
||||||
|
# https://open.feishu.cn/document/ukTMukTMukTM/uEDO4UjLxgDO14SM4gTN
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code
|
||||||
|
}
|
||||||
|
|
||||||
|
data = self._requests.post(URL.GET_USER_INFO_BY_CODE, json=body, check_errcode_is_0=False)
|
||||||
|
|
||||||
|
self._requests.check_errcode_is_0(data)
|
||||||
|
return data['data']['user_id']
|
||||||
|
|
||||||
|
def send_text(self, user_ids, msg):
|
||||||
|
params = {
|
||||||
|
'receive_id_type': 'user_id'
|
||||||
|
}
|
||||||
|
|
||||||
|
body = {
|
||||||
|
'msg_type': 'text',
|
||||||
|
'content': json.dumps({'text': msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid_users = []
|
||||||
|
for user_id in user_ids:
|
||||||
|
body['receive_id'] = user_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
|
||||||
|
except APIException as e:
|
||||||
|
# 只处理可预知的错误
|
||||||
|
logger.exception(e)
|
||||||
|
invalid_users.append(user_id)
|
||||||
|
return invalid_users
|
|
@ -6,7 +6,7 @@ from django.core.cache import cache
|
||||||
from .utils import DictWrapper
|
from .utils import DictWrapper
|
||||||
from common.utils.common import get_logger
|
from common.utils.common import get_logger
|
||||||
from common.utils import lazyproperty
|
from common.utils import lazyproperty
|
||||||
from common.message.backends.utils import set_default
|
from common.message.backends.utils import set_default, as_request
|
||||||
|
|
||||||
from . import exceptions as exce
|
from . import exceptions as exce
|
||||||
|
|
||||||
|
@ -14,17 +14,37 @@ logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RequestMixin:
|
class RequestMixin:
|
||||||
def check_errcode_is_0(self, data: DictWrapper):
|
code_key: str
|
||||||
errcode = data['errcode']
|
msg_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRequest(RequestMixin):
|
||||||
|
"""
|
||||||
|
定义了 `access_token` 的过期刷新框架
|
||||||
|
"""
|
||||||
|
invalid_token_errcodes = ()
|
||||||
|
code_key = 'errcode'
|
||||||
|
msg_key = 'err_msg'
|
||||||
|
|
||||||
|
def __init__(self, timeout=None):
|
||||||
|
self._request_kwargs = {
|
||||||
|
'timeout': timeout
|
||||||
|
}
|
||||||
|
self.init_access_token()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_errcode_is_0(cls, data: DictWrapper):
|
||||||
|
errcode = data[cls.code_key]
|
||||||
if errcode != 0:
|
if errcode != 0:
|
||||||
# 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常
|
# 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常
|
||||||
errmsg = data['errmsg']
|
errmsg = data[cls.msg_key]
|
||||||
logger.error(f'Response 200 but errcode is not 0: '
|
logger.error(f'Response 200 but errcode is not 0: '
|
||||||
f'errcode={errcode} '
|
f'errcode={errcode} '
|
||||||
f'errmsg={errmsg} ')
|
f'errmsg={errmsg} ')
|
||||||
raise exce.ErrCodeNot0(detail=data.raw_data)
|
raise exce.ErrCodeNot0(detail=data.raw_data)
|
||||||
|
|
||||||
def check_http_is_200(self, response):
|
@staticmethod
|
||||||
|
def check_http_is_200(response):
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
# 正常情况下不会返回非 200 响应码
|
# 正常情况下不会返回非 200 响应码
|
||||||
logger.error(f'Response error: '
|
logger.error(f'Response error: '
|
||||||
|
@ -33,25 +53,28 @@ class RequestMixin:
|
||||||
f'\ncontent={response.content}')
|
f'\ncontent={response.content}')
|
||||||
raise exce.HTTPNot200(detail=response.json())
|
raise exce.HTTPNot200(detail=response.json())
|
||||||
|
|
||||||
|
|
||||||
class BaseRequest(RequestMixin):
|
|
||||||
invalid_token_errcode = -1
|
|
||||||
|
|
||||||
def __init__(self, timeout=None):
|
|
||||||
self._request_kwargs = {
|
|
||||||
'timeout': timeout
|
|
||||||
}
|
|
||||||
self.init_access_token()
|
|
||||||
|
|
||||||
def request_access_token(self):
|
def request_access_token(self):
|
||||||
|
"""
|
||||||
|
获取新的 `access_token` 的方法,子类需要实现
|
||||||
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def get_access_token_cache_key(self):
|
def get_access_token_cache_key(self):
|
||||||
|
"""
|
||||||
|
获取 `access_token` 的缓存 key, 子类需要实现
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def add_token(self, kwargs: dict):
|
||||||
|
"""
|
||||||
|
添加 token ,子类需要实现
|
||||||
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def is_token_invalid(self, data):
|
def is_token_invalid(self, data):
|
||||||
errcode = data['errcode']
|
code = data[self.code_key]
|
||||||
if errcode == self.invalid_token_errcode:
|
if code in self.invalid_token_errcodes:
|
||||||
|
logger.error(f'OAuth token invalid: {data}')
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -69,26 +92,58 @@ class BaseRequest(RequestMixin):
|
||||||
def refresh_access_token(self):
|
def refresh_access_token(self):
|
||||||
access_token, expires_in = self.request_access_token()
|
access_token, expires_in = self.request_access_token()
|
||||||
self.access_token = access_token
|
self.access_token = access_token
|
||||||
cache.set(self.access_token_cache_key, access_token, expires_in)
|
cache.set(self.access_token_cache_key, access_token, expires_in - 10)
|
||||||
|
|
||||||
def raw_request(self, method, url, **kwargs):
|
def raw_request(self, method, url, **kwargs):
|
||||||
set_default(kwargs, self._request_kwargs)
|
set_default(kwargs, self._request_kwargs)
|
||||||
raw_data = ''
|
|
||||||
for i in range(3):
|
|
||||||
# 循环为了防止 access_token 失效
|
|
||||||
try:
|
try:
|
||||||
response = getattr(requests, method)(url, **kwargs)
|
response = getattr(requests, method)(url, **kwargs)
|
||||||
self.check_http_is_200(response)
|
self.check_http_is_200(response)
|
||||||
raw_data = response.json()
|
raw_data = response.json()
|
||||||
data = DictWrapper(raw_data)
|
data = DictWrapper(raw_data)
|
||||||
|
|
||||||
|
return data
|
||||||
|
except req_exce.ReadTimeout as e:
|
||||||
|
logger.exception(e)
|
||||||
|
raise exce.NetError
|
||||||
|
|
||||||
|
def token_request(self, method, url, **kwargs):
|
||||||
|
for i in range(3):
|
||||||
|
# 循环为了防止 access_token 失效
|
||||||
|
self.add_token(kwargs)
|
||||||
|
data = self.raw_request(method, url, **kwargs)
|
||||||
|
|
||||||
if self.is_token_invalid(data):
|
if self.is_token_invalid(data):
|
||||||
self.refresh_access_token()
|
self.refresh_access_token()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return data
|
return data
|
||||||
except req_exce.ReadTimeout as e:
|
logger.error(f'Get access_token error, check config: url={url} data={data.raw_data}')
|
||||||
logger.exception(e)
|
raise PermissionDenied(data.raw_data)
|
||||||
raise exce.NetError
|
|
||||||
logger.error(f'Get access_token error, check config: url={url} data={raw_data}')
|
def get(self, url, params=None, with_token=True,
|
||||||
raise PermissionDenied(raw_data)
|
check_errcode_is_0=True, **kwargs):
|
||||||
|
# self.request ...
|
||||||
|
pass
|
||||||
|
get = as_request(get)
|
||||||
|
|
||||||
|
def post(self, url, params=None, json=None,
|
||||||
|
with_token=True, check_errcode_is_0=True,
|
||||||
|
**kwargs):
|
||||||
|
# self.request ...
|
||||||
|
pass
|
||||||
|
post = as_request(post)
|
||||||
|
|
||||||
|
def request(self, method, url,
|
||||||
|
with_token=True,
|
||||||
|
check_errcode_is_0=True,
|
||||||
|
**kwargs):
|
||||||
|
|
||||||
|
if with_token:
|
||||||
|
data = self.token_request(method, url, **kwargs)
|
||||||
|
else:
|
||||||
|
data = self.raw_request(method, url, **kwargs)
|
||||||
|
|
||||||
|
if check_errcode_is_0:
|
||||||
|
self.check_errcode_is_0(data)
|
||||||
|
return data
|
||||||
|
|
|
@ -54,7 +54,7 @@ class DictWrapper:
|
||||||
return str(self.raw_data)
|
return str(self.raw_data)
|
||||||
|
|
||||||
|
|
||||||
def request(func):
|
def as_request(func):
|
||||||
def inner(*args, **kwargs):
|
def inner(*args, **kwargs):
|
||||||
signature = inspect.signature(func)
|
signature = inspect.signature(func)
|
||||||
bound_args = signature.bind(*args, **kwargs)
|
bound_args = signature.bind(*args, **kwargs)
|
||||||
|
|
|
@ -2,13 +2,9 @@ from typing import Iterable, AnyStr
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
from requests.exceptions import ReadTimeout
|
|
||||||
import requests
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
from common.utils.common import get_logger
|
from common.utils.common import get_logger
|
||||||
from common.message.backends.utils import digest, DictWrapper, update_values, set_default
|
from common.message.backends.utils import digest, DictWrapper, update_values, set_default
|
||||||
from common.message.backends.utils import request
|
|
||||||
from common.message.backends.mixin import RequestMixin, BaseRequest
|
from common.message.backends.mixin import RequestMixin, BaseRequest
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
@ -48,7 +44,7 @@ class WeComRequests(BaseRequest):
|
||||||
- 确保 status_code == 200
|
- 确保 status_code == 200
|
||||||
- 确保 access_token 无效时重试
|
- 确保 access_token 无效时重试
|
||||||
"""
|
"""
|
||||||
invalid_token_errcode = ErrorCode.INVALID_TOKEN
|
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
|
||||||
|
|
||||||
def __init__(self, corpid, corpsecret, agentid, timeout=None):
|
def __init__(self, corpid, corpsecret, agentid, timeout=None):
|
||||||
self._corpid = corpid
|
self._corpid = corpid
|
||||||
|
@ -68,36 +64,14 @@ class WeComRequests(BaseRequest):
|
||||||
expires_in = data['expires_in']
|
expires_in = data['expires_in']
|
||||||
return access_token, expires_in
|
return access_token, expires_in
|
||||||
|
|
||||||
@request
|
def add_token(self, kwargs: dict):
|
||||||
def get(self, url, params=None, with_token=True,
|
params = kwargs.get('params')
|
||||||
check_errcode_is_0=True, **kwargs):
|
if params is None:
|
||||||
# self.request ...
|
|
||||||
pass
|
|
||||||
|
|
||||||
@request
|
|
||||||
def post(self, url, params=None, json=None,
|
|
||||||
with_token=True, check_errcode_is_0=True,
|
|
||||||
**kwargs):
|
|
||||||
# self.request ...
|
|
||||||
pass
|
|
||||||
|
|
||||||
def request(self, method, url,
|
|
||||||
params=None,
|
|
||||||
with_token=True,
|
|
||||||
check_errcode_is_0=True,
|
|
||||||
**kwargs):
|
|
||||||
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
params = {}
|
params = {}
|
||||||
|
kwargs['params'] = params
|
||||||
|
|
||||||
if with_token:
|
|
||||||
params['access_token'] = self.access_token
|
params['access_token'] = self.access_token
|
||||||
|
|
||||||
data = self.raw_request(method, url, params=params, **kwargs)
|
|
||||||
if check_errcode_is_0:
|
|
||||||
self.check_errcode_is_0(data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class WeCom(RequestMixin):
|
class WeCom(RequestMixin):
|
||||||
"""
|
"""
|
||||||
|
@ -147,7 +121,7 @@ class WeCom(RequestMixin):
|
||||||
if errcode in (ErrorCode.RECIPIENTS_INVALID, ErrorCode.RECIPIENTS_EMPTY):
|
if errcode in (ErrorCode.RECIPIENTS_INVALID, ErrorCode.RECIPIENTS_EMPTY):
|
||||||
# 全部接收人无权限或不存在
|
# 全部接收人无权限或不存在
|
||||||
return users
|
return users
|
||||||
self.check_errcode_is_0(data)
|
self._requests.check_errcode_is_0(data)
|
||||||
|
|
||||||
invaliduser = data['invaliduser']
|
invaliduser = data['invaliduser']
|
||||||
if not invaliduser:
|
if not invaliduser:
|
||||||
|
@ -173,7 +147,7 @@ class WeCom(RequestMixin):
|
||||||
logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}')
|
logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}')
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
self.check_errcode_is_0(data)
|
self._requests.check_errcode_is_0(data)
|
||||||
|
|
||||||
USER_ID = 'UserId'
|
USER_ID = 'UserId'
|
||||||
OPEN_ID = 'OpenId'
|
OPEN_ID = 'OpenId'
|
||||||
|
|
|
@ -228,6 +228,10 @@ class Config(dict):
|
||||||
'DINGTALK_APPKEY': '',
|
'DINGTALK_APPKEY': '',
|
||||||
'DINGTALK_APPSECRET': '',
|
'DINGTALK_APPSECRET': '',
|
||||||
|
|
||||||
|
'AUTH_FEISHU': False,
|
||||||
|
'FEISHU_APP_ID': '',
|
||||||
|
'FEISHU_APP_SECRET': '',
|
||||||
|
|
||||||
'OTP_VALID_WINDOW': 2,
|
'OTP_VALID_WINDOW': 2,
|
||||||
'OTP_ISSUER_NAME': 'JumpServer',
|
'OTP_ISSUER_NAME': 'JumpServer',
|
||||||
'EMAIL_SUFFIX': 'jumpserver.org',
|
'EMAIL_SUFFIX': 'jumpserver.org',
|
||||||
|
|
|
@ -16,6 +16,7 @@ def jumpserver_processor(request):
|
||||||
'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'),
|
'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'),
|
||||||
'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_logo.png'),
|
'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_logo.png'),
|
||||||
'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_logo.png'),
|
'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_logo.png'),
|
||||||
|
'LOGIN_FEISHU_LOGO_URL': static('img/login_feishu_logo.png'),
|
||||||
'JMS_TITLE': _('JumpServer Open Source Bastion Host'),
|
'JMS_TITLE': _('JumpServer Open Source Bastion Host'),
|
||||||
'VERSION': settings.VERSION,
|
'VERSION': settings.VERSION,
|
||||||
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021',
|
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021',
|
||||||
|
|
|
@ -117,6 +117,10 @@ DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID
|
||||||
DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY
|
DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY
|
||||||
DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET
|
DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET
|
||||||
|
|
||||||
|
# FeiShu auth
|
||||||
|
AUTH_FEISHU = CONFIG.AUTH_FEISHU
|
||||||
|
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
|
||||||
|
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
|
||||||
|
|
||||||
# Other setting
|
# Other setting
|
||||||
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
||||||
|
@ -134,12 +138,13 @@ AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend'
|
||||||
AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication'
|
AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication'
|
||||||
AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication'
|
AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication'
|
||||||
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
||||||
|
AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication'
|
||||||
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
||||||
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
|
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
|
||||||
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_AUTH_TOKEN
|
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_AUTH_TOKEN,
|
||||||
]
|
]
|
||||||
|
|
||||||
if AUTH_CAS:
|
if AUTH_CAS:
|
||||||
|
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -5,6 +5,7 @@ from .dingtalk import DingTalk
|
||||||
from .email import Email
|
from .email import Email
|
||||||
from .site_msg import SiteMessage
|
from .site_msg import SiteMessage
|
||||||
from .wecom import WeCom
|
from .wecom import WeCom
|
||||||
|
from .feishu import FeiShu
|
||||||
|
|
||||||
|
|
||||||
class BACKEND(models.TextChoices):
|
class BACKEND(models.TextChoices):
|
||||||
|
@ -12,6 +13,7 @@ class BACKEND(models.TextChoices):
|
||||||
WECOM = 'wecom', _('WeCom')
|
WECOM = 'wecom', _('WeCom')
|
||||||
DINGTALK = 'dingtalk', _('DingTalk')
|
DINGTALK = 'dingtalk', _('DingTalk')
|
||||||
SITE_MSG = 'site_msg', _('Site message')
|
SITE_MSG = 'site_msg', _('Site message')
|
||||||
|
FEISHU = 'feishu', _('FeiShu')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def client(self):
|
def client(self):
|
||||||
|
@ -19,7 +21,8 @@ class BACKEND(models.TextChoices):
|
||||||
self.EMAIL: Email,
|
self.EMAIL: Email,
|
||||||
self.WECOM: WeCom,
|
self.WECOM: WeCom,
|
||||||
self.DINGTALK: DingTalk,
|
self.DINGTALK: DingTalk,
|
||||||
self.SITE_MSG: SiteMessage
|
self.SITE_MSG: SiteMessage,
|
||||||
|
self.FEISHU: FeiShu,
|
||||||
}[self]
|
}[self]
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from common.message.backends.dingtalk import DingTalk as Client
|
from common.message.backends.dingtalk import DingTalk as Client
|
||||||
from .base import BackendBase
|
from .base import BackendBase
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from common.message.backends.feishu import FeiShu as Client
|
||||||
|
from .base import BackendBase
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShu(BackendBase):
|
||||||
|
account_field = 'feishu_id'
|
||||||
|
is_enable_field_in_settings = 'AUTH_FEISHU'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = Client(
|
||||||
|
app_id=settings.FEISHU_APP_ID,
|
||||||
|
app_secret=settings.FEISHU_APP_SECRET
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_msg(self, users, msg):
|
||||||
|
accounts, __, __ = self.get_accounts(users)
|
||||||
|
return self.client.send_text(accounts, msg)
|
|
@ -2,3 +2,4 @@ from .common import *
|
||||||
from .ldap import *
|
from .ldap import *
|
||||||
from .wecom import *
|
from .wecom import *
|
||||||
from .dingtalk import *
|
from .dingtalk import *
|
||||||
|
from .feishu import *
|
||||||
|
|
|
@ -130,6 +130,7 @@ class PublicSettingApi(generics.RetrieveAPIView):
|
||||||
},
|
},
|
||||||
"AUTH_WECOM": settings.AUTH_WECOM,
|
"AUTH_WECOM": settings.AUTH_WECOM,
|
||||||
"AUTH_DINGTALK": settings.AUTH_DINGTALK,
|
"AUTH_DINGTALK": settings.AUTH_DINGTALK,
|
||||||
|
"AUTH_FEISHU": settings.AUTH_FEISHU,
|
||||||
'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED
|
'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -148,6 +149,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
|
||||||
'email_content': serializers.EmailContentSettingSerializer,
|
'email_content': serializers.EmailContentSettingSerializer,
|
||||||
'wecom': serializers.WeComSettingSerializer,
|
'wecom': serializers.WeComSettingSerializer,
|
||||||
'dingtalk': serializers.DingTalkSettingSerializer,
|
'dingtalk': serializers.DingTalkSettingSerializer,
|
||||||
|
'feishu': serializers.FeiShuSettingSerializer,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
from rest_framework.views import Response
|
||||||
|
from rest_framework.generics import GenericAPIView
|
||||||
|
from rest_framework.exceptions import APIException
|
||||||
|
from rest_framework import status
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from settings.models import Setting
|
||||||
|
from common.permissions import IsSuperUser
|
||||||
|
from common.message.backends.feishu import FeiShu
|
||||||
|
|
||||||
|
from .. import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuTestingAPI(GenericAPIView):
|
||||||
|
permission_classes = (IsSuperUser,)
|
||||||
|
serializer_class = serializers.FeiShuSettingSerializer
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
app_id = serializer.validated_data['FEISHU_APP_ID']
|
||||||
|
app_secret = serializer.validated_data.get('FEISHU_APP_SECRET')
|
||||||
|
|
||||||
|
if not app_secret:
|
||||||
|
secret = Setting.objects.filter(name='FEISHU_APP_SECRET').first()
|
||||||
|
if secret:
|
||||||
|
app_secret = secret.cleaned_value
|
||||||
|
|
||||||
|
app_secret = app_secret or ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
feishu = FeiShu(app_id=app_id, app_secret=app_secret)
|
||||||
|
feishu.send_text(['test'], 'test')
|
||||||
|
return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')})
|
||||||
|
except APIException as e:
|
||||||
|
try:
|
||||||
|
error = e.detail['errmsg']
|
||||||
|
except:
|
||||||
|
error = e.detail
|
||||||
|
return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error})
|
|
@ -7,6 +7,7 @@ __all__ = [
|
||||||
'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer',
|
'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer',
|
||||||
'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer',
|
'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer',
|
||||||
'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer',
|
'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer',
|
||||||
|
'FeiShuSettingSerializer',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -218,6 +219,12 @@ class DingTalkSettingSerializer(serializers.Serializer):
|
||||||
AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth'))
|
AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth'))
|
||||||
|
|
||||||
|
|
||||||
|
class FeiShuSettingSerializer(serializers.Serializer):
|
||||||
|
FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID')
|
||||||
|
FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True)
|
||||||
|
AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth'))
|
||||||
|
|
||||||
|
|
||||||
class SettingsSerializer(
|
class SettingsSerializer(
|
||||||
BasicSettingSerializer,
|
BasicSettingSerializer,
|
||||||
EmailSettingSerializer,
|
EmailSettingSerializer,
|
||||||
|
@ -227,6 +234,7 @@ class SettingsSerializer(
|
||||||
SecuritySettingSerializer,
|
SecuritySettingSerializer,
|
||||||
WeComSettingSerializer,
|
WeComSettingSerializer,
|
||||||
DingTalkSettingSerializer,
|
DingTalkSettingSerializer,
|
||||||
|
FeiShuSettingSerializer,
|
||||||
):
|
):
|
||||||
|
|
||||||
# encrypt_fields 现在使用 write_only 来判断了
|
# encrypt_fields 现在使用 write_only 来判断了
|
||||||
|
|
|
@ -15,6 +15,7 @@ urlpatterns = [
|
||||||
path('ldap/cache/refresh/', api.LDAPCacheRefreshAPI.as_view(), name='ldap-cache-refresh'),
|
path('ldap/cache/refresh/', api.LDAPCacheRefreshAPI.as_view(), name='ldap-cache-refresh'),
|
||||||
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'),
|
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'),
|
||||||
path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'),
|
path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'),
|
||||||
|
path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-testing'),
|
||||||
|
|
||||||
path('setting/', api.SettingsApi.as_view(), name='settings-setting'),
|
path('setting/', api.SettingsApi.as_view(), name='settings-setting'),
|
||||||
path('public/', api.PublicSettingApi.as_view(), name='public-setting'),
|
path('public/', api.PublicSettingApi.as_view(), name='public-setting'),
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-08-06 02:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0035_auto_20210526_1100'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='feishu_id',
|
||||||
|
field=models.CharField(default=None, max_length=128, null=True, unique=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -610,6 +610,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
||||||
)
|
)
|
||||||
wecom_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
wecom_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
||||||
dingtalk_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
dingtalk_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
||||||
|
feishu_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '{0.name}({0.username})'.format(self)
|
return '{0.name}({0.username})'.format(self)
|
||||||
|
@ -628,6 +629,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
||||||
def is_dingtalk_bound(self):
|
def is_dingtalk_bound(self):
|
||||||
return bool(self.dingtalk_id)
|
return bool(self.dingtalk_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_feishu_bound(self):
|
||||||
|
return bool(self.feishu_id)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse('users:user-detail', args=(self.id,))
|
return reverse('users:user-detail', args=(self.id,))
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer):
|
||||||
'mfa_enabled', 'is_valid', 'is_expired', 'is_active', # 布尔字段
|
'mfa_enabled', 'is_valid', 'is_expired', 'is_active', # 布尔字段
|
||||||
'date_expired', 'date_joined', 'last_login', # 日期字段
|
'date_expired', 'date_joined', 'last_login', # 日期字段
|
||||||
'created_by', 'comment', # 通用字段
|
'created_by', 'comment', # 通用字段
|
||||||
'is_wecom_bound', 'is_dingtalk_bound',
|
'is_wecom_bound', 'is_dingtalk_bound', 'is_feishu_bound',
|
||||||
]
|
]
|
||||||
# 包含不太常用的字段,可以没有
|
# 包含不太常用的字段,可以没有
|
||||||
fields_verbose = fields_small + [
|
fields_verbose = fields_small + [
|
||||||
|
|
Loading…
Reference in New Issue