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

* perf: 支持slack通知和认证

* perf: 生成迁移文件

* perf: 优化获取access_token逻辑

---------

Co-authored-by: jiangweidong <weidong.jiang@fit2cloud.com>
This commit is contained in:
fit2bot
2023-11-29 17:45:44 +08:00
committed by GitHub
parent 575562c416
commit 0fdae00722
26 changed files with 523 additions and 114 deletions

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