mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-09-05 01:11:57 +00:00
feat: 添加 飞书 (#6602)
* feat: 添加 飞书 Co-authored-by: xinwen <coderWen@126.com> Co-authored-by: wenyann <64353056+wenyann@users.noreply.github.com>
This commit is contained in:
@@ -9,4 +9,5 @@ from .login_confirm import *
|
||||
from .sso import *
|
||||
from .wecom import *
|
||||
from .dingtalk import *
|
||||
from .feishu import *
|
||||
from .password import *
|
||||
|
45
apps/authentication/api/feishu.py
Normal file
45
apps/authentication/api/feishu.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from users.permissions import IsAuthPasswdTimeValid
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.permissions import IsOrgAdmin
|
||||
from common.mixins.api import RoleUserMixin, RoleAdminMixin
|
||||
from authentication import errors
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class FeiShuQRUnBindBase(APIView):
|
||||
user: User
|
||||
|
||||
def post(self, request: Request, **kwargs):
|
||||
user = self.user
|
||||
|
||||
if not user.feishu_id:
|
||||
raise errors.FeiShuNotBound
|
||||
|
||||
user.feishu_id = None
|
||||
user.save()
|
||||
return Response()
|
||||
|
||||
|
||||
class FeiShuQRUnBindForUserApi(RoleUserMixin, FeiShuQRUnBindBase):
|
||||
permission_classes = (IsAuthPasswdTimeValid,)
|
||||
|
||||
|
||||
class FeiShuQRUnBindForAdminApi(RoleAdminMixin, FeiShuQRUnBindBase):
|
||||
user_id_url_kwarg = 'user_id'
|
||||
permission_classes = (IsOrgAdmin,)
|
||||
|
||||
|
||||
class FeiShuEventSubscriptionCallback(APIView):
|
||||
"""
|
||||
# https://open.feishu.cn/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM
|
||||
"""
|
||||
permission_classes = ()
|
||||
|
||||
def post(self, request: Request, *args, **kwargs):
|
||||
return Response(data=request.data)
|
@@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend):
|
||||
pass
|
||||
|
||||
|
||||
class FeiShuAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationTokenAuthentication(JMSModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
|
@@ -315,6 +315,11 @@ class DingTalkNotBound(JMSException):
|
||||
default_detail = 'DingTalk is not bound'
|
||||
|
||||
|
||||
class FeiShuNotBound(JMSException):
|
||||
default_code = 'feishu_not_bound'
|
||||
default_detail = 'FeiShu is not bound'
|
||||
|
||||
|
||||
class PasswdInvalid(JMSException):
|
||||
default_code = 'passwd_invalid'
|
||||
default_detail = _('Your password is invalid')
|
||||
|
@@ -191,7 +191,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %}
|
||||
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK or AUTH_FEISHU %}
|
||||
<div class="hr-line-dashed"></div>
|
||||
<div style="display: inline-block; float: left">
|
||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||
@@ -215,6 +215,11 @@
|
||||
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_FEISHU %}
|
||||
<a href="{% url 'authentication:feishu-qr-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_FEISHU_LOGO_URL }}" height="13" width="13"></i> {% trans 'FeiShu' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
|
@@ -20,6 +20,10 @@ urlpatterns = [
|
||||
path('dingtalk/qr/unbind/', api.DingTalkQRUnBindForUserApi.as_view(), name='dingtalk-qr-unbind'),
|
||||
path('dingtalk/qr/unbind/<uuid:user_id>/', api.DingTalkQRUnBindForAdminApi.as_view(), name='dingtalk-qr-unbind-for-admin'),
|
||||
|
||||
path('feishu/qr/unbind/', api.FeiShuQRUnBindForUserApi.as_view(), name='feishu-qr-unbind'),
|
||||
path('feishu/qr/unbind/<uuid:user_id>/', api.FeiShuQRUnBindForAdminApi.as_view(), name='feishu-qr-unbind-for-admin'),
|
||||
path('feishu/event/subscription/callback/', api.FeiShuEventSubscriptionCallback.as_view(), name='feishu-event-subscription-callback'),
|
||||
|
||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
||||
|
@@ -37,6 +37,14 @@ urlpatterns = [
|
||||
path('dingtalk/qr/bind/<uuid:user_id>/callback/', views.DingTalkQRBindCallbackView.as_view(), name='dingtalk-qr-bind-callback'),
|
||||
path('dingtalk/qr/login/callback/', views.DingTalkQRLoginCallbackView.as_view(), name='dingtalk-qr-login-callback'),
|
||||
|
||||
path('feishu/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='feishu-bind-success-flash-msg'),
|
||||
path('feishu/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='feishu-bind-failed-flash-msg'),
|
||||
path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'),
|
||||
path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'),
|
||||
path('feishu/qr/login/', views.FeiShuQRLoginView.as_view(), name='feishu-qr-login'),
|
||||
path('feishu/qr/bind/callback/', views.FeiShuQRBindCallbackView.as_view(), name='feishu-qr-bind-callback'),
|
||||
path('feishu/qr/login/callback/', views.FeiShuQRLoginCallbackView.as_view(), name='feishu-qr-login-callback'),
|
||||
|
||||
# Profile
|
||||
path('profile/pubkey/generate/', users_view.UserPublicKeyGenerateView.as_view(), name='user-pubkey-generate'),
|
||||
path('profile/otp/enable/start/', users_view.UserOtpEnableStartView.as_view(), name='user-otp-enable-start'),
|
||||
|
@@ -4,3 +4,4 @@ from .login import *
|
||||
from .mfa import *
|
||||
from .wecom import *
|
||||
from .dingtalk import *
|
||||
from .feishu import *
|
||||
|
253
apps/authentication/views/feishu.py
Normal file
253
apps/authentication/views/feishu.py
Normal file
@@ -0,0 +1,253 @@
|
||||
import urllib
|
||||
|
||||
from django.http.response import HttpResponseRedirect, HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.generic import TemplateView
|
||||
from django.views import View
|
||||
from django.conf import settings
|
||||
from django.http.request import HttpRequest
|
||||
from django.db.utils import IntegrityError
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
from users.utils import is_auth_password_time_valid
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.models import User
|
||||
from common.utils import get_logger
|
||||
from common.utils.random import random_string
|
||||
from common.utils.django import reverse, get_object_or_none
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from common.message.backends.feishu import FeiShu, URL
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
FEISHU_STATE_SESSION_KEY = '_feishu_state'
|
||||
|
||||
|
||||
class FeiShuQRMixin(PermissionsMixin, View):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
try:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
except APIException as e:
|
||||
msg = str(e.detail)
|
||||
return self.get_failed_reponse(
|
||||
'/',
|
||||
_('FeiShu Error'),
|
||||
msg
|
||||
)
|
||||
|
||||
def verify_state(self):
|
||||
state = self.request.GET.get('state')
|
||||
session_state = self.request.session.get(FEISHU_STATE_SESSION_KEY)
|
||||
if state != session_state:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_verify_state_failed_response(self, redirect_uri):
|
||||
msg = _("You've been hacked")
|
||||
return self.get_failed_reponse(redirect_uri, msg, msg)
|
||||
|
||||
def get_qr_url(self, redirect_uri):
|
||||
state = random_string(16)
|
||||
self.request.session[FEISHU_STATE_SESSION_KEY] = state
|
||||
|
||||
params = {
|
||||
'app_id': settings.FEISHU_APP_ID,
|
||||
'state': state,
|
||||
'redirect_uri': redirect_uri,
|
||||
}
|
||||
url = URL.AUTHEN + '?' + urllib.parse.urlencode(params)
|
||||
return url
|
||||
|
||||
def get_success_reponse(self, redirect_url, title, msg):
|
||||
ok_flash_msg_url = reverse('authentication:feishu-bind-success-flash-msg')
|
||||
ok_flash_msg_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
'title': title,
|
||||
'msg': msg
|
||||
})
|
||||
return HttpResponseRedirect(ok_flash_msg_url)
|
||||
|
||||
def get_failed_reponse(self, redirect_url, title, msg):
|
||||
failed_flash_msg_url = reverse('authentication:feishu-bind-failed-flash-msg')
|
||||
failed_flash_msg_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url,
|
||||
'title': title,
|
||||
'msg': msg
|
||||
})
|
||||
return HttpResponseRedirect(failed_flash_msg_url)
|
||||
|
||||
def get_already_bound_response(self, redirect_url):
|
||||
msg = _('FeiShu is already bound')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class FeiShuQRBindView(FeiShuQRMixin, View):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
user = request.user
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
if not is_auth_password_time_valid(request.session):
|
||||
msg = _('Please verify your password first')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
redirect_uri = reverse('authentication:feishu-qr-bind-callback', external=True)
|
||||
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
code = request.GET.get('code')
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
if not self.verify_state():
|
||||
return self.get_verify_state_failed_response(redirect_url)
|
||||
|
||||
user = request.user
|
||||
|
||||
if user.feishu_id:
|
||||
response = self.get_already_bound_response(redirect_url)
|
||||
return response
|
||||
|
||||
feishu = FeiShu(
|
||||
app_id=settings.FEISHU_APP_ID,
|
||||
app_secret=settings.FEISHU_APP_SECRET
|
||||
)
|
||||
user_id = feishu.get_user_id_by_code(code)
|
||||
|
||||
if not user_id:
|
||||
msg = _('FeiShu query user failed')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
try:
|
||||
user.feishu_id = user_id
|
||||
user.save()
|
||||
except IntegrityError as e:
|
||||
if e.args[0] == 1062:
|
||||
msg = _('The FeiShu is already bound to another user')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
raise e
|
||||
|
||||
msg = _('Binding FeiShu successfully')
|
||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class FeiShuEnableStartView(UserVerifyPasswordView):
|
||||
|
||||
def get_success_url(self):
|
||||
referer = self.request.META.get('HTTP_REFERER')
|
||||
redirect_url = self.request.GET.get("redirect_url")
|
||||
|
||||
success_url = reverse('authentication:feishu-qr-bind')
|
||||
|
||||
success_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url or referer
|
||||
})
|
||||
|
||||
return success_url
|
||||
|
||||
|
||||
class FeiShuQRLoginView(FeiShuQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:feishu-qr-login-callback', external=True)
|
||||
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
code = request.GET.get('code')
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
login_url = reverse('authentication:login')
|
||||
|
||||
if not self.verify_state():
|
||||
return self.get_verify_state_failed_response(redirect_url)
|
||||
|
||||
feishu = FeiShu(
|
||||
app_id=settings.FEISHU_APP_ID,
|
||||
app_secret=settings.FEISHU_APP_SECRET
|
||||
)
|
||||
user_id = feishu.get_user_id_by_code(code)
|
||||
if not user_id:
|
||||
# 正常流程不会出这个错误,hack 行为
|
||||
msg = _('Failed to get user from FeiShu')
|
||||
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
user = get_object_or_none(User, feishu_id=user_id)
|
||||
if user is None:
|
||||
title = _('FeiShu is not bound')
|
||||
msg = _('Please login with a password and then bind the WeCom')
|
||||
response = self.get_failed_reponse(login_url, title=title, msg=msg)
|
||||
return response
|
||||
|
||||
try:
|
||||
self.check_oauth2_auth(user, settings.AUTH_BACKEND_FEISHU)
|
||||
except errors.AuthFailedError as e:
|
||||
self.set_login_failed_mark()
|
||||
msg = e.msg
|
||||
response = self.get_failed_reponse(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashFeiShuBindSucceedMsgView(TemplateView):
|
||||
template_name = 'flash_message_standalone.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
title = request.GET.get('title')
|
||||
msg = request.GET.get('msg')
|
||||
|
||||
context = {
|
||||
'title': title or _('Binding FeiShu successfully'),
|
||||
'messages': msg or _('Binding FeiShu successfully'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashFeiShuBindFailedMsgView(TemplateView):
|
||||
template_name = 'flash_message_standalone.html'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
title = request.GET.get('title')
|
||||
msg = request.GET.get('msg')
|
||||
|
||||
context = {
|
||||
'title': title or _('Binding FeiShu failed'),
|
||||
'messages': msg or _('Binding FeiShu failed'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
@@ -154,6 +154,7 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||
'AUTH_CAS': settings.AUTH_CAS,
|
||||
'AUTH_WECOM': settings.AUTH_WECOM,
|
||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
||||
'AUTH_FEISHU': settings.AUTH_FEISHU,
|
||||
'rsa_public_key': rsa_public_key,
|
||||
'forgot_password_url': forgot_password_url
|
||||
}
|
||||
|
Reference in New Issue
Block a user