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 .wecom import *
|
||||
from .dingtalk import *
|
||||
from .feishu 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
|
||||
|
||||
|
||||
class FeiShuAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
|
|
|
@ -315,6 +315,11 @@ class DingTalkNotBound(JMSException):
|
|||
default_detail = 'DingTalk is not bound'
|
||||
|
||||
|
||||
class FeiShuNotBound(JMSException):
|
||||
default_code = 'feishu_not_bound'
|
||||
default_detail = 'FeiShu is not bound'
|
||||
|
||||
|
||||
class PasswdInvalid(JMSException):
|
||||
default_code = 'passwd_invalid'
|
||||
default_detail = _('Your password is invalid')
|
||||
|
|
|
@ -191,7 +191,7 @@
|
|||
</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 style="display: inline-block; float: left">
|
||||
<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' %}
|
||||
</a>
|
||||
{% 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>
|
||||
{% else %}
|
||||
|
|
|
@ -20,6 +20,10 @@ urlpatterns = [
|
|||
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('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('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
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/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
|
||||
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'),
|
||||
|
|
|
@ -4,3 +4,4 @@ from .login import *
|
|||
from .mfa import *
|
||||
from .wecom 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_WECOM': settings.AUTH_WECOM,
|
||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
||||
'AUTH_FEISHU': settings.AUTH_FEISHU,
|
||||
'rsa_public_key': rsa_public_key,
|
||||
'forgot_password_url': forgot_password_url
|
||||
}
|
||||
|
|
|
@ -2,8 +2,7 @@ import time
|
|||
import hmac
|
||||
import base64
|
||||
|
||||
from common.message.backends.utils import request
|
||||
from common.message.backends.utils import digest
|
||||
from common.message.backends.utils import digest, as_request
|
||||
from common.message.backends.mixin import BaseRequest
|
||||
|
||||
|
||||
|
@ -34,7 +33,7 @@ class URL:
|
|||
|
||||
|
||||
class DingTalkRequests(BaseRequest):
|
||||
invalid_token_errcode = ErrorCode.INVALID_TOKEN
|
||||
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
|
||||
|
||||
def __init__(self, appid, appsecret, agentid, timeout=None):
|
||||
self._appid = appid
|
||||
|
@ -55,21 +54,33 @@ class DingTalkRequests(BaseRequest):
|
|||
expires_in = data['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,
|
||||
with_token=False, with_sign=False,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
pass
|
||||
get = as_request(get)
|
||||
|
||||
@request
|
||||
def post(self, url, json=None, params=None,
|
||||
with_token=False, with_sign=False,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
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))
|
||||
signature = sign(self._appsecret, timestamp)
|
||||
accessKey = self._appid
|
||||
|
@ -78,23 +89,17 @@ class DingTalkRequests(BaseRequest):
|
|||
params['signature'] = signature
|
||||
params['accessKey'] = accessKey
|
||||
|
||||
def request(self, method, url, params=None,
|
||||
def request(self, method, url,
|
||||
with_token=False, with_sign=False,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
|
||||
if with_token:
|
||||
params['access_token'] = self.access_token
|
||||
|
||||
if with_sign:
|
||||
self._add_sign(params)
|
||||
|
||||
data = self.raw_request(method, url, params=params, **kwargs)
|
||||
if check_errcode_is_0:
|
||||
self.check_errcode_is_0(data)
|
||||
self._add_sign(kwargs)
|
||||
|
||||
data = super().request(
|
||||
method, url, with_token=with_token,
|
||||
check_errcode_is_0=check_errcode_is_0, **kwargs)
|
||||
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 common.utils.common import get_logger
|
||||
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
|
||||
|
||||
|
@ -14,17 +14,37 @@ logger = get_logger(__name__)
|
|||
|
||||
|
||||
class RequestMixin:
|
||||
def check_errcode_is_0(self, data: DictWrapper):
|
||||
errcode = data['errcode']
|
||||
code_key: str
|
||||
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:
|
||||
# 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常
|
||||
errmsg = data['errmsg']
|
||||
errmsg = data[cls.msg_key]
|
||||
logger.error(f'Response 200 but errcode is not 0: '
|
||||
f'errcode={errcode} '
|
||||
f'errmsg={errmsg} ')
|
||||
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:
|
||||
# 正常情况下不会返回非 200 响应码
|
||||
logger.error(f'Response error: '
|
||||
|
@ -33,25 +53,28 @@ class RequestMixin:
|
|||
f'\ncontent={response.content}')
|
||||
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):
|
||||
"""
|
||||
获取新的 `access_token` 的方法,子类需要实现
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_access_token_cache_key(self):
|
||||
"""
|
||||
获取 `access_token` 的缓存 key, 子类需要实现
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def add_token(self, kwargs: dict):
|
||||
"""
|
||||
添加 token ,子类需要实现
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def is_token_invalid(self, data):
|
||||
errcode = data['errcode']
|
||||
if errcode == self.invalid_token_errcode:
|
||||
code = data[self.code_key]
|
||||
if code in self.invalid_token_errcodes:
|
||||
logger.error(f'OAuth token invalid: {data}')
|
||||
return True
|
||||
return False
|
||||
|
||||
|
@ -69,26 +92,58 @@ class BaseRequest(RequestMixin):
|
|||
def refresh_access_token(self):
|
||||
access_token, expires_in = self.request_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):
|
||||
set_default(kwargs, self._request_kwargs)
|
||||
raw_data = ''
|
||||
try:
|
||||
response = getattr(requests, method)(url, **kwargs)
|
||||
self.check_http_is_200(response)
|
||||
raw_data = response.json()
|
||||
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 失效
|
||||
try:
|
||||
response = getattr(requests, method)(url, **kwargs)
|
||||
self.check_http_is_200(response)
|
||||
raw_data = response.json()
|
||||
data = DictWrapper(raw_data)
|
||||
self.add_token(kwargs)
|
||||
data = self.raw_request(method, url, **kwargs)
|
||||
|
||||
if self.is_token_invalid(data):
|
||||
self.refresh_access_token()
|
||||
continue
|
||||
if self.is_token_invalid(data):
|
||||
self.refresh_access_token()
|
||||
continue
|
||||
|
||||
return data
|
||||
except req_exce.ReadTimeout as e:
|
||||
logger.exception(e)
|
||||
raise exce.NetError
|
||||
logger.error(f'Get access_token error, check config: url={url} data={raw_data}')
|
||||
raise PermissionDenied(raw_data)
|
||||
return data
|
||||
logger.error(f'Get access_token error, check config: url={url} data={data.raw_data}')
|
||||
raise PermissionDenied(data.raw_data)
|
||||
|
||||
def get(self, url, params=None, with_token=True,
|
||||
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)
|
||||
|
||||
|
||||
def request(func):
|
||||
def as_request(func):
|
||||
def inner(*args, **kwargs):
|
||||
signature = inspect.signature(func)
|
||||
bound_args = signature.bind(*args, **kwargs)
|
||||
|
|
|
@ -2,13 +2,9 @@ from typing import Iterable, AnyStr
|
|||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
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.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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
@ -48,7 +44,7 @@ class WeComRequests(BaseRequest):
|
|||
- 确保 status_code == 200
|
||||
- 确保 access_token 无效时重试
|
||||
"""
|
||||
invalid_token_errcode = ErrorCode.INVALID_TOKEN
|
||||
invalid_token_errcodes = (ErrorCode.INVALID_TOKEN,)
|
||||
|
||||
def __init__(self, corpid, corpsecret, agentid, timeout=None):
|
||||
self._corpid = corpid
|
||||
|
@ -68,35 +64,13 @@ class WeComRequests(BaseRequest):
|
|||
expires_in = data['expires_in']
|
||||
return access_token, expires_in
|
||||
|
||||
@request
|
||||
def get(self, url, params=None, with_token=True,
|
||||
check_errcode_is_0=True, **kwargs):
|
||||
# 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):
|
||||
def add_token(self, kwargs: dict):
|
||||
params = kwargs.get('params')
|
||||
if params is None:
|
||||
params = {}
|
||||
kwargs['params'] = params
|
||||
|
||||
if with_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
|
||||
params['access_token'] = self.access_token
|
||||
|
||||
|
||||
class WeCom(RequestMixin):
|
||||
|
@ -147,7 +121,7 @@ class WeCom(RequestMixin):
|
|||
if errcode in (ErrorCode.RECIPIENTS_INVALID, ErrorCode.RECIPIENTS_EMPTY):
|
||||
# 全部接收人无权限或不存在
|
||||
return users
|
||||
self.check_errcode_is_0(data)
|
||||
self._requests.check_errcode_is_0(data)
|
||||
|
||||
invaliduser = data['invaliduser']
|
||||
if not invaliduser:
|
||||
|
@ -173,7 +147,7 @@ class WeCom(RequestMixin):
|
|||
logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}')
|
||||
return None, None
|
||||
|
||||
self.check_errcode_is_0(data)
|
||||
self._requests.check_errcode_is_0(data)
|
||||
|
||||
USER_ID = 'UserId'
|
||||
OPEN_ID = 'OpenId'
|
||||
|
|
|
@ -228,6 +228,10 @@ class Config(dict):
|
|||
'DINGTALK_APPKEY': '',
|
||||
'DINGTALK_APPSECRET': '',
|
||||
|
||||
'AUTH_FEISHU': False,
|
||||
'FEISHU_APP_ID': '',
|
||||
'FEISHU_APP_SECRET': '',
|
||||
|
||||
'OTP_VALID_WINDOW': 2,
|
||||
'OTP_ISSUER_NAME': 'JumpServer',
|
||||
'EMAIL_SUFFIX': 'jumpserver.org',
|
||||
|
|
|
@ -16,6 +16,7 @@ def jumpserver_processor(request):
|
|||
'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'),
|
||||
'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_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'),
|
||||
'VERSION': settings.VERSION,
|
||||
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021',
|
||||
|
|
|
@ -117,6 +117,10 @@ DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID
|
|||
DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY
|
||||
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
|
||||
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_WECOM = 'authentication.backends.api.WeComAuthentication'
|
||||
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
||||
AUTH_BACKEND_FEISHU = 'authentication.backends.api.FeiShuAuthentication'
|
||||
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
||||
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
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:
|
||||
|
|
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 .site_msg import SiteMessage
|
||||
from .wecom import WeCom
|
||||
from .feishu import FeiShu
|
||||
|
||||
|
||||
class BACKEND(models.TextChoices):
|
||||
|
@ -12,6 +13,7 @@ class BACKEND(models.TextChoices):
|
|||
WECOM = 'wecom', _('WeCom')
|
||||
DINGTALK = 'dingtalk', _('DingTalk')
|
||||
SITE_MSG = 'site_msg', _('Site message')
|
||||
FEISHU = 'feishu', _('FeiShu')
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
|
@ -19,7 +21,8 @@ class BACKEND(models.TextChoices):
|
|||
self.EMAIL: Email,
|
||||
self.WECOM: WeCom,
|
||||
self.DINGTALK: DingTalk,
|
||||
self.SITE_MSG: SiteMessage
|
||||
self.SITE_MSG: SiteMessage,
|
||||
self.FEISHU: FeiShu,
|
||||
}[self]
|
||||
return client
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from django.conf import settings
|
||||
|
||||
from common.message.backends.dingtalk import DingTalk as Client
|
||||
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 .wecom import *
|
||||
from .dingtalk import *
|
||||
from .feishu import *
|
||||
|
|
|
@ -130,6 +130,7 @@ class PublicSettingApi(generics.RetrieveAPIView):
|
|||
},
|
||||
"AUTH_WECOM": settings.AUTH_WECOM,
|
||||
"AUTH_DINGTALK": settings.AUTH_DINGTALK,
|
||||
"AUTH_FEISHU": settings.AUTH_FEISHU,
|
||||
'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED
|
||||
}
|
||||
}
|
||||
|
@ -148,6 +149,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
|
|||
'email_content': serializers.EmailContentSettingSerializer,
|
||||
'wecom': serializers.WeComSettingSerializer,
|
||||
'dingtalk': serializers.DingTalkSettingSerializer,
|
||||
'feishu': serializers.FeiShuSettingSerializer,
|
||||
}
|
||||
|
||||
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',
|
||||
'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer',
|
||||
'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer',
|
||||
'FeiShuSettingSerializer',
|
||||
]
|
||||
|
||||
|
||||
|
@ -218,6 +219,12 @@ class DingTalkSettingSerializer(serializers.Serializer):
|
|||
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(
|
||||
BasicSettingSerializer,
|
||||
EmailSettingSerializer,
|
||||
|
@ -227,6 +234,7 @@ class SettingsSerializer(
|
|||
SecuritySettingSerializer,
|
||||
WeComSettingSerializer,
|
||||
DingTalkSettingSerializer,
|
||||
FeiShuSettingSerializer,
|
||||
):
|
||||
|
||||
# encrypt_fields 现在使用 write_only 来判断了
|
||||
|
|
|
@ -15,6 +15,7 @@ urlpatterns = [
|
|||
path('ldap/cache/refresh/', api.LDAPCacheRefreshAPI.as_view(), name='ldap-cache-refresh'),
|
||||
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-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('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)
|
||||
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):
|
||||
return '{0.name}({0.username})'.format(self)
|
||||
|
@ -628,6 +629,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
|||
def is_dingtalk_bound(self):
|
||||
return bool(self.dingtalk_id)
|
||||
|
||||
@property
|
||||
def is_feishu_bound(self):
|
||||
return bool(self.feishu_id)
|
||||
|
||||
def get_absolute_url(self):
|
||||
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', # 布尔字段
|
||||
'date_expired', 'date_joined', 'last_login', # 日期字段
|
||||
'created_by', 'comment', # 通用字段
|
||||
'is_wecom_bound', 'is_dingtalk_bound',
|
||||
'is_wecom_bound', 'is_dingtalk_bound', 'is_feishu_bound',
|
||||
]
|
||||
# 包含不太常用的字段,可以没有
|
||||
fields_verbose = fields_small + [
|
||||
|
|
Loading…
Reference in New Issue