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:
fit2bot
2021-08-12 16:44:06 +08:00
committed by GitHub
parent a2907a6e6d
commit 54751a715c
32 changed files with 975 additions and 356 deletions

View File

@@ -9,4 +9,5 @@ from .login_confirm import *
from .sso import *
from .wecom import *
from .dingtalk import *
from .feishu import *
from .password import *

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

View File

@@ -240,6 +240,15 @@ class DingTalkAuthentication(JMSModelBackend):
pass
class FeiShuAuthentication(JMSModelBackend):
"""
什么也不做呀😺
"""
def authenticate(self, request, **kwargs):
pass
class AuthorizationTokenAuthentication(JMSModelBackend):
"""
什么也不做呀😺

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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