feat: 添加 飞书 (#6602)

* feat: 添加 飞书

Co-authored-by: xinwen <coderWen@126.com>
Co-authored-by: wenyann <64353056+wenyann@users.noreply.github.com>
pull/6633/head
fit2bot 2021-08-12 16:44:06 +08:00 committed by GitHub
parent a2907a6e6d
commit 54751a715c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 975 additions and 356 deletions

View File

@ -9,4 +9,5 @@ from .login_confirm import *
from .sso import *
from .wecom import *
from .dingtalk import *
from .feishu import *
from .password import *

View File

@ -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)

View File

@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend):
pass
class FeiShuAuthentication(JMSModelBackend):
"""
什么也不做呀😺
"""
def authenticate(self, request, **kwargs):
pass
class AuthorizationTokenAuthentication(JMSModelBackend):
"""
什么也不做呀😺

View File

@ -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')

View File

@ -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 %}

View File

@ -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'),

View File

@ -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'),

View File

@ -4,3 +4,4 @@ from .login import *
from .mfa import *
from .wecom import *
from .dingtalk import *
from .feishu import *

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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'

View File

@ -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',

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,4 @@
from django.conf import settings
from common.message.backends.dingtalk import DingTalk as Client
from .base import BackendBase

View File

@ -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)

View File

@ -2,3 +2,4 @@ from .common import *
from .ldap import *
from .wecom import *
from .dingtalk import *
from .feishu import *

View File

@ -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):

View File

@ -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})

View File

@ -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 来判断了

View File

@ -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

View File

@ -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),
),
]

View File

@ -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,))

View File

@ -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 + [