mirror of https://github.com/jumpserver/jumpserver
perf: 支持slack通知和认证 (#12193)
* perf: 支持slack通知和认证 * perf: 生成迁移文件 * perf: 优化获取access_token逻辑 --------- Co-authored-by: jiangweidong <weidong.jiang@fit2cloud.com>pull/12229/head
parent
575562c416
commit
0fdae00722
|
@ -36,6 +36,7 @@ class AuthBackendLabelMapping(LazyObject):
|
|||
backend_label_mapping[settings.AUTH_BACKEND_AUTH_TOKEN] = _("Auth Token")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _("WeCom")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_FEISHU] = _("FeiShu")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_SLACK] = _("Slack")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _("DingTalk")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_TEMP_TOKEN] = _("Temporary token")
|
||||
backend_label_mapping[settings.AUTH_BACKEND_PASSKEY] = _("Passkey")
|
||||
|
|
|
@ -55,6 +55,19 @@ class FeiShuAuthentication(JMSModelBackend):
|
|||
pass
|
||||
|
||||
|
||||
class SlackAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_enabled():
|
||||
return settings.AUTH_SLACK
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
|
|
|
@ -27,7 +27,7 @@ urlpatterns = [
|
|||
path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'),
|
||||
path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'),
|
||||
path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'),
|
||||
path('wecom/qr/bind/<uuid:user_id>/callback/', views.WeComQRBindCallbackView.as_view(),
|
||||
path('wecom/qr/bind/callback/', views.WeComQRBindCallbackView.as_view(),
|
||||
name='wecom-qr-bind-callback'),
|
||||
path('wecom/qr/login/callback/', views.WeComQRLoginCallbackView.as_view(), name='wecom-qr-login-callback'),
|
||||
path('wecom/oauth/login/', views.WeComOAuthLoginView.as_view(), name='wecom-oauth-login'),
|
||||
|
@ -49,6 +49,12 @@ urlpatterns = [
|
|||
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'),
|
||||
|
||||
path('slack/bind/start/', views.SlackEnableStartView.as_view(), name='slack-bind-start'),
|
||||
path('slack/qr/bind/', views.SlackQRBindView.as_view(), name='slack-qr-bind'),
|
||||
path('slack/qr/login/', views.SlackQRLoginView.as_view(), name='slack-qr-login'),
|
||||
path('slack/qr/bind/callback/', views.SlackQRBindCallbackView.as_view(), name='slack-qr-bind-callback'),
|
||||
path('slack/qr/login/callback/', views.SlackQRLoginCallbackView.as_view(), name='slack-qr-login-callback'),
|
||||
|
||||
# Profile
|
||||
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
|
||||
path('profile/mfa/', users_view.MFASettingView.as_view(), name='user-mfa-setting'),
|
||||
|
|
|
@ -5,3 +5,4 @@ from .mfa import *
|
|||
from .wecom import *
|
||||
from .dingtalk import *
|
||||
from .feishu import *
|
||||
from .slack import *
|
||||
|
|
|
@ -2,6 +2,7 @@ from functools import lru_cache
|
|||
|
||||
from django.conf import settings
|
||||
from django.db.utils import IntegrityError
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
|
@ -9,8 +10,10 @@ from rest_framework.request import Request
|
|||
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
from authentication.notifications import OAuthBindMessage
|
||||
from common.utils import get_logger
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from common.utils.common import get_request_ip
|
||||
from users.models import User
|
||||
from users.signal_handlers import check_only_allow_exist_user_auth
|
||||
from .mixins import FlashMessageMixin
|
||||
|
@ -18,9 +21,21 @@ from .mixins import FlashMessageMixin
|
|||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, View):
|
||||
class IMClientMixin:
|
||||
client_type_path = ''
|
||||
client_auth_params = {}
|
||||
|
||||
@property
|
||||
@lru_cache(maxsize=1)
|
||||
def client(self):
|
||||
if not all([self.client_type_path, self.client_auth_params]):
|
||||
raise NotImplementedError
|
||||
client_init = {k: getattr(settings, v) for k, v in self.client_auth_params.items()}
|
||||
client_type = import_string(self.client_type_path)
|
||||
return client_type(**client_init)
|
||||
|
||||
|
||||
class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, IMClientMixin, View):
|
||||
user_type = ''
|
||||
auth_backend = None
|
||||
# 提示信息
|
||||
|
@ -34,15 +49,6 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, View):
|
|||
def get_verify_state_failed_response(self, redirect_uri):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@lru_cache(maxsize=1)
|
||||
def client(self):
|
||||
if not all([self.client_type_path, self.client_auth_params]):
|
||||
raise NotImplementedError
|
||||
client_init = {k: getattr(settings, v) for k, v in self.client_auth_params.items()}
|
||||
client_type = import_string(self.client_type_path)
|
||||
return client_type(**client_init)
|
||||
|
||||
def create_user_if_not_exist(self, user_id, **kwargs):
|
||||
user = None
|
||||
user_attr = self.client.get_user_detail(user_id, **kwargs)
|
||||
|
@ -99,3 +105,53 @@ class BaseLoginCallbackView(AuthMixin, FlashMessageMixin, View):
|
|||
response = self.get_failed_response(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
class BaseBindCallbackView(FlashMessageMixin, IMClientMixin, View):
|
||||
auth_type = ''
|
||||
auth_type_label = ''
|
||||
|
||||
def verify_state(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_verify_state_failed_response(self, redirect_uri):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_already_bound_response(self, redirect_uri):
|
||||
raise NotImplementedError
|
||||
|
||||
def get(self, request: Request):
|
||||
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
|
||||
source_id = getattr(user, f'{self.auth_type}_id', None)
|
||||
if source_id:
|
||||
response = self.get_already_bound_response(redirect_url)
|
||||
return response
|
||||
|
||||
auth_user_id, __ = self.client.get_user_id_by_code(code)
|
||||
if not auth_user_id:
|
||||
msg = _('%s query user failed') % self.auth_type_label
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
try:
|
||||
setattr(user, f'{self.auth_type}_id', auth_user_id)
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
if e.args[0] == 1062:
|
||||
msg = _('The %s is already bound to another user') % self.auth_type_label
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
raise e
|
||||
|
||||
ip = get_request_ip(request)
|
||||
OAuthBindMessage(user, ip, self.auth_type_label, auth_user_id).publish_async()
|
||||
msg = _('Binding %s successfully') % self.auth_type_label
|
||||
auth_logout(request)
|
||||
response = self.get_success_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -11,16 +9,14 @@ from rest_framework.exceptions import APIException
|
|||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.notifications import OAuthBindMessage
|
||||
from authentication.permissions import UserConfirmation
|
||||
from common.sdk.im.feishu import URL, FeiShu
|
||||
from common.sdk.im.feishu import URL
|
||||
from common.utils import get_logger
|
||||
from common.utils.common import get_request_ip
|
||||
from common.utils.django import reverse
|
||||
from common.utils.random import random_string
|
||||
from common.views.mixins import PermissionsMixin, UserConfirmRequiredExceptionMixin
|
||||
from users.views import UserVerifyPasswordView
|
||||
from .base import BaseLoginCallbackView
|
||||
from .base import BaseLoginCallbackView, BaseBindCallbackView
|
||||
from .mixins import FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
@ -82,49 +78,13 @@ class FeiShuQRBindView(FeiShuQRMixin, View):
|
|||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
|
||||
class FeiShuQRBindCallbackView(FeiShuQRMixin, BaseBindCallbackView):
|
||||
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_response(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_response(redirect_url, msg, msg)
|
||||
return response
|
||||
raise e
|
||||
|
||||
ip = get_request_ip(request)
|
||||
OAuthBindMessage(user, ip, _('FeiShu'), user_id).publish_async()
|
||||
msg = _('Binding FeiShu successfully')
|
||||
auth_logout(request)
|
||||
response = self.get_success_response(redirect_url, msg, msg)
|
||||
return response
|
||||
client_type_path = 'common.sdk.im.feishu.FeiShu'
|
||||
client_auth_params = {'app_id': 'FEISHU_APP_ID', 'app_secret': 'FEISHU_APP_SECRET'}
|
||||
auth_type = 'feishu'
|
||||
auth_type_label = _('FeiShu')
|
||||
|
||||
|
||||
class FeiShuEnableStartView(UserVerifyPasswordView):
|
||||
|
|
|
@ -91,6 +91,12 @@ class UserLoginContextMixin:
|
|||
'url': reverse('authentication:feishu-qr-login'),
|
||||
'logo': static('img/login_feishu_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _('Slack'),
|
||||
'enabled': settings.AUTH_SLACK,
|
||||
'url': reverse('authentication:slack-qr-login'),
|
||||
'logo': static('img/login_slack_logo.png')
|
||||
},
|
||||
{
|
||||
'name': _("Passkey"),
|
||||
'enabled': settings.AUTH_PASSKEY,
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.permissions import UserConfirmation
|
||||
from common.sdk.im.slack import URL, SLACK_REDIRECT_URI_SESSION_KEY
|
||||
from common.utils import get_logger
|
||||
from common.utils.django import reverse
|
||||
from common.utils.random import random_string
|
||||
from common.views.mixins import PermissionsMixin, UserConfirmRequiredExceptionMixin
|
||||
from users.views import UserVerifyPasswordView
|
||||
from .base import BaseLoginCallbackView, BaseBindCallbackView
|
||||
from .mixins import FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
SLACK_STATE_SESSION_KEY = '_slack_state'
|
||||
|
||||
|
||||
class SlackMixin(UserConfirmRequiredExceptionMixin, PermissionsMixin, FlashMessageMixin, 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_response(
|
||||
'/',
|
||||
_('Slack Error'),
|
||||
msg
|
||||
)
|
||||
|
||||
def verify_state(self):
|
||||
state = self.request.GET.get('state')
|
||||
session_state = self.request.session.get(SLACK_STATE_SESSION_KEY)
|
||||
if state != session_state:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_verify_state_failed_response(self, redirect_uri):
|
||||
msg = _("The system configuration is incorrect. Please contact your administrator")
|
||||
return self.get_failed_response(redirect_uri, msg, msg)
|
||||
|
||||
def get_qr_url(self, redirect_uri):
|
||||
state = random_string(16)
|
||||
self.request.session[SLACK_STATE_SESSION_KEY] = state
|
||||
|
||||
params = {
|
||||
'client_id': settings.SLACK_CLIENT_ID,
|
||||
'state': state, 'scope': 'users:read,users:read.email',
|
||||
'redirect_uri': redirect_uri,
|
||||
}
|
||||
url = URL().AUTHORIZE + '?' + urlencode(params)
|
||||
return url
|
||||
|
||||
def get_already_bound_response(self, redirect_url):
|
||||
msg = _('Slack is already bound')
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class SlackQRBindView(SlackMixin, View):
|
||||
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN))
|
||||
|
||||
def get(self, request: Request):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:slack-qr-bind-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
self.request.session[SLACK_REDIRECT_URI_SESSION_KEY] = redirect_uri
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class SlackQRBindCallbackView(SlackMixin, BaseBindCallbackView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
client_type_path = 'common.sdk.im.slack.Slack'
|
||||
client_auth_params = {'client_id': 'SLACK_CLIENT_ID', 'client_secret': 'SLACK_CLIENT_SECRET'}
|
||||
auth_type = 'slack'
|
||||
auth_type_label = _('Slack')
|
||||
|
||||
|
||||
class SlackEnableStartView(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:slack-qr-bind')
|
||||
success_url += '?' + urlencode({
|
||||
'redirect_url': redirect_url or referer
|
||||
})
|
||||
|
||||
return success_url
|
||||
|
||||
|
||||
class SlackQRLoginView(SlackMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get(self, request: Request):
|
||||
redirect_url = request.GET.get('redirect_url') or reverse('index')
|
||||
redirect_uri = reverse('authentication:slack-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
})
|
||||
self.request.session[SLACK_REDIRECT_URI_SESSION_KEY] = redirect_uri
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class SlackQRLoginCallbackView(SlackMixin, BaseLoginCallbackView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
client_type_path = 'common.sdk.im.slack.Slack'
|
||||
client_auth_params = {'client_id': 'SLACK_CLIENT_ID', 'client_secret': 'SLACK_CLIENT_SECRET'}
|
||||
user_type = 'slack'
|
||||
auth_backend = 'AUTH_BACKEND_SLACK'
|
||||
|
||||
msg_client_err = _('Slack Error')
|
||||
msg_user_not_bound_err = _('Slack is not bound')
|
||||
msg_not_found_user_from_client_err = _('Failed to get user from Slack')
|
|
@ -1,8 +1,6 @@
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.db.utils import IntegrityError
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -13,7 +11,6 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
|
|||
from authentication import errors
|
||||
from authentication.const import ConfirmType
|
||||
from authentication.mixins import AuthMixin
|
||||
from authentication.notifications import OAuthBindMessage
|
||||
from authentication.permissions import UserConfirmation
|
||||
from common.sdk.im.wecom import URL
|
||||
from common.sdk.im.wecom import WeCom
|
||||
|
@ -24,7 +21,7 @@ from common.utils.random import random_string
|
|||
from common.views.mixins import UserConfirmRequiredExceptionMixin, PermissionsMixin
|
||||
from users.models import User
|
||||
from users.views import UserVerifyPasswordView
|
||||
from .base import BaseLoginCallbackView
|
||||
from .base import BaseLoginCallbackView, BaseBindCallbackView
|
||||
from .mixins import METAMixin, FlashMessageMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
@ -104,64 +101,21 @@ class WeComQRBindView(WeComQRMixin, View):
|
|||
permission_classes = (IsAuthenticated, UserConfirmation.require(ConfirmType.RELOGIN))
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
user = request.user
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:wecom-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
|
||||
redirect_uri = reverse('authentication:wecom-qr-bind-callback', external=True)
|
||||
redirect_uri += '?' + urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class WeComQRBindCallbackView(WeComQRMixin, View):
|
||||
class WeComQRBindCallbackView(WeComQRMixin, BaseBindCallbackView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request: HttpRequest, user_id):
|
||||
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 = get_object_or_none(User, id=user_id)
|
||||
if user is None:
|
||||
logger.error(f'WeComQR bind callback error, user_id invalid: user_id={user_id}')
|
||||
msg = _('Invalid user_id')
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
if user.wecom_id:
|
||||
response = self.get_already_bound_response(redirect_url)
|
||||
return response
|
||||
|
||||
wecom = WeCom(
|
||||
corpid=settings.WECOM_CORPID,
|
||||
corpsecret=settings.WECOM_SECRET,
|
||||
agentid=settings.WECOM_AGENTID
|
||||
)
|
||||
wecom_userid, __ = wecom.get_user_id_by_code(code)
|
||||
if not wecom_userid:
|
||||
msg = _('WeCom query user failed')
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
try:
|
||||
user.wecom_id = wecom_userid
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
if e.args[0] == 1062:
|
||||
msg = _('The WeCom is already bound to another user')
|
||||
response = self.get_failed_response(redirect_url, msg, msg)
|
||||
return response
|
||||
raise e
|
||||
|
||||
ip = get_request_ip(request)
|
||||
OAuthBindMessage(user, ip, _('WeCom'), wecom_userid).publish_async()
|
||||
msg = _('Binding WeCom successfully')
|
||||
auth_logout(request)
|
||||
response = self.get_success_response(redirect_url, msg, msg)
|
||||
return response
|
||||
client_type_path = 'common.sdk.im.wecom.WeCom'
|
||||
client_auth_params = {'corpid': 'WECOM_CORPID', 'corpsecret': 'WECOM_SECRET', 'agentid': 'WECOM_AGENTID'}
|
||||
auth_type = 'wecom'
|
||||
auth_type_label = _('Wecom')
|
||||
|
||||
|
||||
class WeComEnableStartView(UserVerifyPasswordView):
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
import requests
|
||||
import mistune
|
||||
|
||||
from rest_framework.exceptions import APIException
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from users.utils import construct_user_email
|
||||
from common.utils.common import get_logger
|
||||
from jumpserver.utils import get_current_request
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
SLACK_REDIRECT_URI_SESSION_KEY = '_slack_redirect_uri'
|
||||
|
||||
|
||||
class URL:
|
||||
AUTHORIZE = 'https://slack.com/oauth/v2/authorize'
|
||||
ACCESS_TOKEN = 'https://slack.com/api/oauth.v2.access'
|
||||
GET_USER_INFO_BY_USER_ID = 'https://slack.com/api/users.info'
|
||||
SEND_MESSAGE = 'https://slack.com/api/chat.postMessage'
|
||||
AUTH_TEST = 'https://slack.com/api/auth.test'
|
||||
|
||||
|
||||
class SlackRenderer(mistune.Renderer):
|
||||
def header(self, text, level, raw=None):
|
||||
return '*' + text + '*\n'
|
||||
|
||||
def double_emphasis(self, text):
|
||||
return '*' + text + '*'
|
||||
|
||||
def list(self, body, ordered=True):
|
||||
lines = body.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if not line:
|
||||
continue
|
||||
prefix = '• '
|
||||
lines[i] = prefix + line[4:-5]
|
||||
return '\n'.join(lines)
|
||||
|
||||
def link(self, link, title, content):
|
||||
if title or content:
|
||||
label = str(title or content).strip()
|
||||
return f'<{link}|{label}>'
|
||||
return f'<{link}>'
|
||||
|
||||
def paragraph(self, text):
|
||||
return f'{text.strip()}\n'
|
||||
|
||||
def linebreak(self):
|
||||
return '\n'
|
||||
|
||||
|
||||
class SlackRequests:
|
||||
def __init__(self, client_id=None, client_secret=None, bot_token=None):
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._bot_token = bot_token
|
||||
self.access_token = None
|
||||
self.user_id = None
|
||||
|
||||
def add_token(self, headers, with_bot_token, with_access_token):
|
||||
if with_access_token:
|
||||
headers.update({'Authorization': f'Bearer {self.access_token}'})
|
||||
if with_bot_token:
|
||||
headers.update({'Authorization': f'Bearer {self._bot_token}'})
|
||||
|
||||
def request(self, method, url, with_bot_token=True, with_access_token=False, **kwargs):
|
||||
headers = kwargs.pop('headers', {})
|
||||
self.add_token(headers, with_bot_token=with_bot_token, with_access_token=with_access_token)
|
||||
|
||||
func_handler = getattr(requests, method, requests.get)
|
||||
data = func_handler(url, headers=headers, **kwargs).json()
|
||||
if not data.get('ok'):
|
||||
raise APIException(
|
||||
detail=data.get('error', _('Unknown error occur'))
|
||||
)
|
||||
return data
|
||||
|
||||
def request_access_token(self, code):
|
||||
request = get_current_request()
|
||||
data = {
|
||||
'code': code, 'client_id': self._client_id, 'client_secret': self._client_secret,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': request.session.get(SLACK_REDIRECT_URI_SESSION_KEY)
|
||||
}
|
||||
response = self.request(
|
||||
'post', url=URL().ACCESS_TOKEN, data=data, with_bot_token=False
|
||||
)
|
||||
self.access_token = response['access_token']
|
||||
self.user_id = response['authed_user']['id']
|
||||
|
||||
|
||||
class Slack:
|
||||
def __init__(self, client_id=None, client_secret=None, bot_token=None, **kwargs):
|
||||
self._client = SlackRequests(
|
||||
client_id=client_id, client_secret=client_secret, bot_token=bot_token
|
||||
)
|
||||
self.markdown = mistune.Markdown(renderer=SlackRenderer())
|
||||
|
||||
def get_user_id_by_code(self, code):
|
||||
self._client.request_access_token(code)
|
||||
response = self._client.request(
|
||||
'get', f'{URL().GET_USER_INFO_BY_USER_ID}?user={self._client.user_id}',
|
||||
with_bot_token=False, with_access_token=True
|
||||
)
|
||||
return self._client.user_id, response['user']
|
||||
|
||||
def is_valid(self):
|
||||
return self._client.request('post', URL().AUTH_TEST)
|
||||
|
||||
def convert_to_markdown(self, message):
|
||||
blocks = []
|
||||
for line in message.split('\n'):
|
||||
block = self.markdown(line)
|
||||
if not block:
|
||||
continue
|
||||
if block.startswith('<hr>'):
|
||||
block_item = {'type': 'divider'}
|
||||
else:
|
||||
block_item = {
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": block}
|
||||
}
|
||||
blocks.append(block_item)
|
||||
return {'blocks': blocks}
|
||||
|
||||
def send_text(self, user_ids, msg_body):
|
||||
body = self.convert_to_markdown(msg_body)
|
||||
logger.info(f'Slack send text: user_ids={user_ids} msg={body}')
|
||||
for user_id in user_ids:
|
||||
body['channel'] = user_id
|
||||
try:
|
||||
self._client.request('post', URL().SEND_MESSAGE, json=body)
|
||||
except APIException as e:
|
||||
# 只处理可预知的错误
|
||||
logger.exception(e)
|
||||
|
||||
@staticmethod
|
||||
def get_user_detail(user_id, **kwargs):
|
||||
# get_user_id_by_code 已经返回个人信息,这里直接解析
|
||||
user_info = kwargs['other_info']
|
||||
username = user_info.get('name') or user_id
|
||||
name = user_info.get('real_name', username)
|
||||
email = user_info.get('profile', {}).get('email')
|
||||
email = construct_user_email(username, email)
|
||||
return {
|
||||
'username': username, 'name': name, 'email': email
|
||||
}
|
|
@ -405,6 +405,12 @@ class Config(dict):
|
|||
'FEISHU_APP_SECRET': '',
|
||||
'FEISHU_VERSION': 'feishu',
|
||||
|
||||
# Slack
|
||||
'AUTH_SLACK': False,
|
||||
'SLACK_CLIENT_ID': '',
|
||||
'SLACK_CLIENT_SECRET': '',
|
||||
'SLACK_BOT_TOKEN': '',
|
||||
|
||||
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2
|
||||
'LOGIN_REDIRECT_MSG_ENABLED': True,
|
||||
|
||||
|
|
|
@ -140,6 +140,12 @@ FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
|
|||
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
|
||||
FEISHU_VERSION = CONFIG.FEISHU_VERSION
|
||||
|
||||
# Slack auth
|
||||
AUTH_SLACK = CONFIG.AUTH_SLACK
|
||||
SLACK_CLIENT_ID = CONFIG.SLACK_CLIENT_ID
|
||||
SLACK_CLIENT_SECRET = CONFIG.SLACK_CLIENT_SECRET
|
||||
SLACK_BOT_TOKEN = CONFIG.SLACK_BOT_TOKEN
|
||||
|
||||
# Saml2 auth
|
||||
AUTH_SAML2 = CONFIG.AUTH_SAML2
|
||||
AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT = CONFIG.AUTH_SAML2_PROVIDER_AUTHORIZATION_ENDPOINT
|
||||
|
@ -201,6 +207,7 @@ AUTH_BACKEND_SSO = 'authentication.backends.sso.SSOAuthentication'
|
|||
AUTH_BACKEND_WECOM = 'authentication.backends.sso.WeComAuthentication'
|
||||
AUTH_BACKEND_DINGTALK = 'authentication.backends.sso.DingTalkAuthentication'
|
||||
AUTH_BACKEND_FEISHU = 'authentication.backends.sso.FeiShuAuthentication'
|
||||
AUTH_BACKEND_SLACK = 'authentication.backends.sso.SlackAuthentication'
|
||||
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.sso.AuthorizationTokenAuthentication'
|
||||
AUTH_BACKEND_SAML2 = 'authentication.backends.saml2.SAML2Backend'
|
||||
AUTH_BACKEND_OAUTH2 = 'authentication.backends.oauth2.OAuth2Backend'
|
||||
|
@ -216,7 +223,7 @@ AUTHENTICATION_BACKENDS = [
|
|||
AUTH_BACKEND_CAS, AUTH_BACKEND_OIDC_PASSWORD, AUTH_BACKEND_OIDC_CODE, AUTH_BACKEND_SAML2,
|
||||
AUTH_BACKEND_OAUTH2,
|
||||
# 扫码模式
|
||||
AUTH_BACKEND_WECOM, AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU,
|
||||
AUTH_BACKEND_WECOM, AUTH_BACKEND_DINGTALK, AUTH_BACKEND_FEISHU, AUTH_BACKEND_SLACK,
|
||||
# Token模式
|
||||
AUTH_BACKEND_AUTH_TOKEN, AUTH_BACKEND_SSO, AUTH_BACKEND_TEMP_TOKEN,
|
||||
AUTH_BACKEND_PASSKEY
|
||||
|
|
|
@ -12,6 +12,7 @@ class BACKEND(models.TextChoices):
|
|||
DINGTALK = 'dingtalk', _('DingTalk')
|
||||
SITE_MSG = 'site_msg', _('Site message')
|
||||
FEISHU = 'feishu', _('FeiShu')
|
||||
SLACK = 'slack', _('Slack')
|
||||
# SMS = 'sms', _('SMS')
|
||||
|
||||
@property
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
from django.conf import settings
|
||||
|
||||
from common.sdk.im.slack import Slack as Client
|
||||
from .base import BackendBase
|
||||
|
||||
|
||||
class Slack(BackendBase):
|
||||
account_field = 'slack_id'
|
||||
is_enable_field_in_settings = 'AUTH_SLACK'
|
||||
|
||||
def __init__(self):
|
||||
self.client = Client(
|
||||
bot_token=settings.SLACK_BOT_TOKEN,
|
||||
)
|
||||
|
||||
def send_msg(self, users, message, subject=None):
|
||||
accounts, __, __ = self.get_accounts(users)
|
||||
return self.client.send_text(accounts, message)
|
||||
|
||||
|
||||
backend = Slack
|
|
@ -202,6 +202,9 @@ class Message(metaclass=MessageType):
|
|||
def get_site_msg_msg(self) -> dict:
|
||||
return self.html_msg
|
||||
|
||||
def get_slack_msg(self) -> dict:
|
||||
return self.markdown_msg
|
||||
|
||||
def get_sms_msg(self) -> dict:
|
||||
return self.text_msg_with_sign
|
||||
|
||||
|
|
|
@ -8,3 +8,4 @@ from .settings import *
|
|||
from .sms import *
|
||||
from .vault import *
|
||||
from .wecom import *
|
||||
from .slack import *
|
||||
|
|
|
@ -39,6 +39,7 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
|
|||
'wecom': serializers.WeComSettingSerializer,
|
||||
'dingtalk': serializers.DingTalkSettingSerializer,
|
||||
'feishu': serializers.FeiShuSettingSerializer,
|
||||
'slack': serializers.SlackSettingSerializer,
|
||||
'auth': serializers.AuthSettingSerializer,
|
||||
'oidc': serializers.OIDCSettingSerializer,
|
||||
'keycloak': serializers.KeycloakSettingSerializer,
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
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.sdk.im.slack import Slack
|
||||
|
||||
from .. import serializers
|
||||
|
||||
|
||||
class SlackTestingAPI(GenericAPIView):
|
||||
serializer_class = serializers.SlackSettingSerializer
|
||||
rbac_perms = {
|
||||
'POST': 'settings.change_auth'
|
||||
}
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
bot_token = serializer.validated_data.get('SLACK_BOT_TOKEN')
|
||||
if not bot_token:
|
||||
secret = Setting.objects.filter(name='SLACK_BOT_TOKEN').first()
|
||||
if secret:
|
||||
bot_token = secret.cleaned_value
|
||||
|
||||
bot_token = bot_token or ''
|
||||
|
||||
try:
|
||||
slack = Slack(bot_token=bot_token)
|
||||
slack.is_valid()
|
||||
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})
|
|
@ -11,3 +11,4 @@ from .saml2 import *
|
|||
from .sms import *
|
||||
from .sso import *
|
||||
from .wecom import *
|
||||
from .slack import *
|
||||
|
|
|
@ -17,7 +17,8 @@ class AuthSettingSerializer(serializers.Serializer):
|
|||
AUTH_RADIUS = serializers.BooleanField(required=False, label=_('RADIUS Auth'))
|
||||
AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('DingTalk Auth'))
|
||||
AUTH_FEISHU = serializers.BooleanField(default=False, label=_('FeiShu Auth'))
|
||||
AUTH_WECOM = serializers.BooleanField(default=False, label=_('WeCom Auth'))
|
||||
AUTH_WECOM = serializers.BooleanField(default=False, label=_('Slack Auth'))
|
||||
AUTH_SLACK = serializers.BooleanField(default=False, label=_('WeCom Auth'))
|
||||
AUTH_SSO = serializers.BooleanField(default=False, label=_("SSO Auth"))
|
||||
AUTH_PASSKEY = serializers.BooleanField(default=False, label=_("Passkey Auth"))
|
||||
FORGOT_PASSWORD_URL = serializers.CharField(
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from common.serializers.fields import EncryptedField
|
||||
|
||||
__all__ = ['SlackSettingSerializer']
|
||||
|
||||
|
||||
class SlackSettingSerializer(serializers.Serializer):
|
||||
PREFIX_TITLE = _('Slack')
|
||||
|
||||
AUTH_SLACK = serializers.BooleanField(default=False, label=_('Enable Slack Auth'))
|
||||
SLACK_CLIENT_ID = serializers.CharField(max_length=256, required=True, label='Client ID')
|
||||
SLACK_CLIENT_SECRET = EncryptedField(max_length=256, required=False, label='Client Secret')
|
||||
SLACK_BOT_TOKEN = EncryptedField(max_length=256, required=False, label='Client bot Token')
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
|||
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('slack/testing/', api.SlackTestingAPI.as_view(), name='slack-testing'),
|
||||
path('sms/<str:backend>/testing/', api.SMSTestingAPI.as_view(), name='sms-testing'),
|
||||
path('sms/backend/', api.SMSBackendAPI.as_view(), name='sms-backend'),
|
||||
path('vault/testing/', api.VaultTestingAPI.as_view(), name='vault-testing'),
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 4.1.10 on 2023-11-23 08:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0048_wechat_phone_encrypt'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='user',
|
||||
unique_together={('feishu_id',), ('dingtalk_id',), ('wecom_id',)},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='slack_id',
|
||||
field=models.CharField(default=None, max_length=128, null=True, verbose_name='Slack'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='source',
|
||||
field=models.CharField(choices=[('local', 'Local'), ('ldap', 'LDAP/AD'), ('openid', 'OpenID'), ('radius', 'Radius'), ('cas', 'CAS'), ('saml2', 'SAML2'), ('oauth2', 'OAuth2'), ('wecom', 'WeCom'), ('dingtalk', 'DingTalk'), ('feishu', 'FeiShu'), ('slack', 'Slack'), ('custom', 'Custom')], default='local', max_length=30, verbose_name='Source'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='user',
|
||||
unique_together={('slack_id',), ('feishu_id',), ('dingtalk_id',), ('wecom_id',)},
|
||||
),
|
||||
]
|
|
@ -746,6 +746,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, JSONFilterMixin, Abstract
|
|||
wecom = 'wecom', _('WeCom')
|
||||
dingtalk = 'dingtalk', _('DingTalk')
|
||||
feishu = 'feishu', _('FeiShu')
|
||||
slack = 'slack', _('Slack')
|
||||
custom = 'custom', 'Custom'
|
||||
|
||||
SOURCE_BACKEND_MAPPING = {
|
||||
|
@ -778,6 +779,9 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, JSONFilterMixin, Abstract
|
|||
Source.feishu: [
|
||||
settings.AUTH_BACKEND_FEISHU
|
||||
],
|
||||
Source.slack: [
|
||||
settings.AUTH_BACKEND_SLACK
|
||||
],
|
||||
Source.dingtalk: [
|
||||
settings.AUTH_BACKEND_DINGTALK
|
||||
],
|
||||
|
@ -848,6 +852,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, JSONFilterMixin, Abstract
|
|||
wecom_id = models.CharField(null=True, default=None, max_length=128, verbose_name=_('WeCom'))
|
||||
dingtalk_id = models.CharField(null=True, default=None, max_length=128, verbose_name=_('DingTalk'))
|
||||
feishu_id = models.CharField(null=True, default=None, max_length=128, verbose_name=_('FeiShu'))
|
||||
slack_id = models.CharField(null=True, default=None, max_length=128, verbose_name=_('Slack'))
|
||||
|
||||
DATE_EXPIRED_WARNING_DAYS = 5
|
||||
|
||||
|
@ -990,6 +995,7 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, JSONFilterMixin, Abstract
|
|||
('dingtalk_id',),
|
||||
('wecom_id',),
|
||||
('feishu_id',),
|
||||
('slack_id',),
|
||||
)
|
||||
permissions = [
|
||||
('invite_user', _('Can invite user')),
|
||||
|
|
|
@ -121,7 +121,7 @@ class UserSerializer(RolesSerializerMixin, CommonBulkSerializerMixin, serializer
|
|||
# small 指的是 不需要计算的直接能从一张表中获取到的数据
|
||||
fields_small = fields_mini + fields_write_only + [
|
||||
"email", "wechat", "phone", "mfa_level", "source",
|
||||
"wecom_id", "dingtalk_id", "feishu_id",
|
||||
"wecom_id", "dingtalk_id", "feishu_id", "slack_id",
|
||||
"created_by", "updated_by", "comment", # 通用字段
|
||||
]
|
||||
fields_date = [
|
||||
|
|
Loading…
Reference in New Issue