perf: 支持slack通知和认证 (#12193)

* perf: 支持slack通知和认证

* perf: 生成迁移文件

* perf: 优化获取access_token逻辑

---------

Co-authored-by: jiangweidong <weidong.jiang@fit2cloud.com>
pull/12229/head
fit2bot 2023-11-29 17:45:44 +08:00 committed by GitHub
parent 575562c416
commit 0fdae00722
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 523 additions and 114 deletions

View File

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

View File

@ -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):
"""
什么也不做呀😺

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,3 +8,4 @@ from .settings import *
from .sms import *
from .vault import *
from .wecom import *
from .slack import *

View File

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

View File

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

View File

@ -11,3 +11,4 @@ from .saml2 import *
from .sms import *
from .sso import *
from .wecom import *
from .slack import *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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