From 0fdae007220d6cda3e4c128c7fe45e47bf2c555d Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Wed, 29 Nov 2023 17:45:44 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E6=94=AF=E6=8C=81slack=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=92=8C=E8=AE=A4=E8=AF=81=20(#12193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 支持slack通知和认证 * perf: 生成迁移文件 * perf: 优化获取access_token逻辑 --------- Co-authored-by: jiangweidong --- apps/audits/signal_handlers/login_log.py | 1 + apps/authentication/backends/sso.py | 13 ++ apps/authentication/urls/view_urls.py | 8 +- apps/authentication/views/__init__.py | 1 + apps/authentication/views/base.py | 76 +++++++-- apps/authentication/views/feishu.py | 54 +------ apps/authentication/views/login.py | 6 + apps/authentication/views/slack.py | 128 +++++++++++++++ apps/authentication/views/wecom.py | 60 +------ apps/common/sdk/im/slack/__init__.py | 149 ++++++++++++++++++ apps/jumpserver/conf.py | 6 + apps/jumpserver/settings/auth.py | 9 +- apps/notifications/backends/__init__.py | 1 + apps/notifications/backends/slack.py | 21 +++ apps/notifications/notifications.py | 3 + apps/settings/api/__init__.py | 1 + apps/settings/api/settings.py | 1 + apps/settings/api/slack.py | 40 +++++ apps/settings/serializers/auth/__init__.py | 1 + apps/settings/serializers/auth/base.py | 3 +- apps/settings/serializers/auth/slack.py | 15 ++ apps/settings/urls/api_urls.py | 1 + apps/static/img/login_slack_logo.png | Bin 0 -> 2221 bytes ..._unique_together_user_slack_id_and_more.py | 31 ++++ apps/users/models/user.py | 6 + apps/users/serializers/user.py | 2 +- 26 files changed, 523 insertions(+), 114 deletions(-) create mode 100644 apps/authentication/views/slack.py create mode 100644 apps/common/sdk/im/slack/__init__.py create mode 100644 apps/notifications/backends/slack.py create mode 100644 apps/settings/api/slack.py create mode 100644 apps/settings/serializers/auth/slack.py create mode 100644 apps/static/img/login_slack_logo.png create mode 100644 apps/users/migrations/0049_alter_user_unique_together_user_slack_id_and_more.py diff --git a/apps/audits/signal_handlers/login_log.py b/apps/audits/signal_handlers/login_log.py index 7765e470b..5829e4f5e 100644 --- a/apps/audits/signal_handlers/login_log.py +++ b/apps/audits/signal_handlers/login_log.py @@ -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") diff --git a/apps/authentication/backends/sso.py b/apps/authentication/backends/sso.py index 7bee484dc..66f15a3a4 100644 --- a/apps/authentication/backends/sso.py +++ b/apps/authentication/backends/sso.py @@ -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): """ 什么也不做呀😺 diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index ea08eddd0..a648ded51 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -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//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'), diff --git a/apps/authentication/views/__init__.py b/apps/authentication/views/__init__.py index 38cf114e1..214c8943a 100644 --- a/apps/authentication/views/__init__.py +++ b/apps/authentication/views/__init__.py @@ -5,3 +5,4 @@ from .mfa import * from .wecom import * from .dingtalk import * from .feishu import * +from .slack import * diff --git a/apps/authentication/views/base.py b/apps/authentication/views/base.py index c118680f0..598ec9a0d 100644 --- a/apps/authentication/views/base.py +++ b/apps/authentication/views/base.py @@ -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 diff --git a/apps/authentication/views/feishu.py b/apps/authentication/views/feishu.py index 4ceacc21b..0427c2b1b 100644 --- a/apps/authentication/views/feishu.py +++ b/apps/authentication/views/feishu.py @@ -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): diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 0b88a76d8..23ad4d814 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -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, diff --git a/apps/authentication/views/slack.py b/apps/authentication/views/slack.py new file mode 100644 index 000000000..70edcf4c5 --- /dev/null +++ b/apps/authentication/views/slack.py @@ -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') diff --git a/apps/authentication/views/wecom.py b/apps/authentication/views/wecom.py index 2e6d0c1bb..48268e516 100644 --- a/apps/authentication/views/wecom.py +++ b/apps/authentication/views/wecom.py @@ -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): diff --git a/apps/common/sdk/im/slack/__init__.py b/apps/common/sdk/im/slack/__init__.py new file mode 100644 index 000000000..1f2532602 --- /dev/null +++ b/apps/common/sdk/im/slack/__init__.py @@ -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('
'): + 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 + } diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index ea33d178a..6d014c80e 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -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, diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index 5b6a1cd35..94c45b135 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -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 diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py index 2e95bd437..047123e57 100644 --- a/apps/notifications/backends/__init__.py +++ b/apps/notifications/backends/__init__.py @@ -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 diff --git a/apps/notifications/backends/slack.py b/apps/notifications/backends/slack.py new file mode 100644 index 000000000..95e0bde60 --- /dev/null +++ b/apps/notifications/backends/slack.py @@ -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 diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py index 19a86ac94..5ceb629c3 100644 --- a/apps/notifications/notifications.py +++ b/apps/notifications/notifications.py @@ -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 diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index 510925590..d8b0e5c2f 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -8,3 +8,4 @@ from .settings import * from .sms import * from .vault import * from .wecom import * +from .slack import * diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py index 6b5f096cf..e0879bd62 100644 --- a/apps/settings/api/settings.py +++ b/apps/settings/api/settings.py @@ -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, diff --git a/apps/settings/api/slack.py b/apps/settings/api/slack.py new file mode 100644 index 000000000..3a4ad289d --- /dev/null +++ b/apps/settings/api/slack.py @@ -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}) diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py index aeca390ac..2b641114c 100644 --- a/apps/settings/serializers/auth/__init__.py +++ b/apps/settings/serializers/auth/__init__.py @@ -11,3 +11,4 @@ from .saml2 import * from .sms import * from .sso import * from .wecom import * +from .slack import * diff --git a/apps/settings/serializers/auth/base.py b/apps/settings/serializers/auth/base.py index 57034e730..fbc833124 100644 --- a/apps/settings/serializers/auth/base.py +++ b/apps/settings/serializers/auth/base.py @@ -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( diff --git a/apps/settings/serializers/auth/slack.py b/apps/settings/serializers/auth/slack.py new file mode 100644 index 000000000..019137f4c --- /dev/null +++ b/apps/settings/serializers/auth/slack.py @@ -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') diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py index 39a755845..2e36fa41d 100644 --- a/apps/settings/urls/api_urls.py +++ b/apps/settings/urls/api_urls.py @@ -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//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'), diff --git a/apps/static/img/login_slack_logo.png b/apps/static/img/login_slack_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..4d0797eda8ff320aa58db4206805e8ca4ef5d113 GIT binary patch literal 2221 zcmV;e2vYZnP)Px-XGugsRCr$PTW?HM*%kktHv_nKcL5o<$-2xa?RL}Xj3@#Y-F5iT25=co8ca}P z^wZk3+nTif;MSoNvBYgnwkhexHfh>sg+|geKrJM@n=mHIRuGYaZS9({%mdkfz(AWd z{F`^0Hwezan|b%mj8Q;7^3J*EoZmh7+;h%7Ls;W~w8rhn69Argz#2#3;U57HOU`{> z$y;1JX9R1f$7A*a3OpXg2%k~fhXz35KPU27K@)%%?khEuqRdrZdq1lQ9?~|7nQ~iy z?ox;wK}fyNHzENCZ8KFr!0Qnt(GX!$$C^6hwlo4U5qqTYn#0%b{&CaOxmDT*rh=F>v zNe7^?BQVDxlb+!9)9EP-UQp54X!_4Heflwiq4>&bbl29C9sWJ30PMLswslT2^|OEm z47%J^lwPiqZf|urGND3%4VVq)`CPR(g=htck_tfnwZJhV`lEncmE>+`QAWt+9c}JG z05%am0-fkBJ@Y#eS`nJ00`SU}&tIHMN&QGfFoQ?BiZVBa0BEfq1hE(Z3clC7zvhC7 z3=x{70-y>k=$QBtz)$ed@>dYtJVV+jNa7|*dZ~U z1))p}h*b}O-Vq4@zN&(t5yKZ~R|EjBT=~>KosyQ%3K_pSB)YeLdc4hByk$ZQpkx8C ztNHXxQ>oaSI-_*mJW%Ts*$iD{)ZAWTdV>jxr=W~mQ21Eq%bC5&JOYkZw;FJ3L3nCm zT>^s4e9l*P_H-;DVgeAesuO`%gN7N?iZZva`(?}ZWE=rUYxS=|{G+Jq=v0wW0oeV) z#7>3L9jBc!*$4<+FUr`EC;++ddUBOuut!fv2`GrxPlyUY!L7J8wB~s&I&A zr8T*W!Bfx!Ow1KMWoQ2?9s^WLW-92I@PkOZNom;X`wRqTh3mTZWJX+Fj<)Jw0{jyo z>TA`siBQ;6QnQ#th#dhQ_FXsIqP5p!<^j%jHs4E|{5Y(&9k1E*d_*&>C>NF*zqq5V z)#pL{x`rnNh)gBDC7#Z31#|&m8crGdG^hKu-$#vSx|yIVPz{P-rsZA&BR?JlNRp-y z(ih=fhnCOesi`bb=tkCSQzL$8SBH$3*WDBXfyzGXO5w zIFg#P>$Ps5(}lmB5N`)6OU7A3Zda5-#~>qF~&oPGG1B>pxyVw3AbPh(>( z_751?9j|dBd|BmvCZgqWAZiToTMx^CNFBiM+vJ9CCj!9eUcds<^7_?K2BJ6ZN_Eqawb!O#dS+_#3p7QAZJ(A zF|qcj%e<62n_U0H1%ZhH;MhsvYe3ZNGcGU`@Y~P1`lF84=%&JQcGVdseglYTi%&7L zd-G7k`Eacg0YF`6{BMqJiaB%}=v||_U`U{#0_B{=)gOObJY34ZbB@`c3eM*LH%;kq zeDs&wS{ft@z`~m2@8zWj@6DDnDPPgGbSXg)U@5ihs|Ri-=V8Q%0?7ivh|EN3b&r4% z0Z#ykGY=ST*)|A}=7@}<@Vg{Qr#8BLNgIp`-_*y#17ZR2Kw18c#nm7E3CxJAhS5GI z05}E#{UIp^|8%3PKjP+z)9yvK6$Jo@r!3|Di!Z>58n5!NEC5u6&E~CHWj#qtn;#(n zbk`iPoLDFntFLhT|`10oAhR~?&XtP}%j~5!1ot~w< zP2Wx4jgV9iP>u0hkEo4E-D#2m`EE)v=9`w>SYaAlcU!-K$*g1@=+|A_NNh+t0CM)> zGfX<;@`PcVzdqv0ZLE3Q5EI27mj~5aT)xM9TwXrEFw@BDOEED>p%JFwL5u6dmGhICa3Iw7+CHaOD)#C>QiSuV9^t&=RA{CCXeus$ vd<0BM4}y=thJ-{u0U#mxRsr*x_kjNcXRUK?=|RF800000NkvXXu0mjfYV#oj literal 0 HcmV?d00001 diff --git a/apps/users/migrations/0049_alter_user_unique_together_user_slack_id_and_more.py b/apps/users/migrations/0049_alter_user_unique_together_user_slack_id_and_more.py new file mode 100644 index 000000000..5847af8c2 --- /dev/null +++ b/apps/users/migrations/0049_alter_user_unique_together_user_slack_id_and_more.py @@ -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',)}, + ), + ] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index b8dd467c6..4ce6dcddb 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -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')), diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index 59ae59b52..269672ab0 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -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 = [