From 52709d2efa7fd6c5afba0b20da5198819d6b11f4 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Tue, 29 Mar 2022 13:19:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E3=80=81=E9=92=89=E9=92=89=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=85=8D=E5=AF=86=E7=99=BB=E5=BD=95(=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E5=B7=B2=E5=AE=9E=E7=8E=B0)=20(#7855)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加oauth接口 * feat: 企业微信支持OAuth认证,工作台免密登录 * feat: 钉钉支持OAuth认证,工作台免密登录 * fix: 修复参数错误 Co-authored-by: halo --- apps/authentication/urls/view_urls.py | 4 + apps/authentication/views/dingtalk.py | 106 ++++++++++++++++++++---- apps/authentication/views/wecom.py | 105 +++++++++++++++++++---- apps/common/sdk/im/dingtalk/__init__.py | 1 + apps/common/sdk/im/wecom/__init__.py | 1 + 5 files changed, 188 insertions(+), 29 deletions(-) diff --git a/apps/authentication/urls/view_urls.py b/apps/authentication/urls/view_urls.py index ce0d3c647..2d0749470 100644 --- a/apps/authentication/urls/view_urls.py +++ b/apps/authentication/urls/view_urls.py @@ -27,12 +27,16 @@ urlpatterns = [ path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'), 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'), + path('wecom/oauth/login/callback/', views.WeComOAuthLoginCallbackView.as_view(), name='wecom-oauth-login-callback'), path('dingtalk/bind/start/', views.DingTalkEnableStartView.as_view(), name='dingtalk-bind-start'), path('dingtalk/qr/bind/', views.DingTalkQRBindView.as_view(), name='dingtalk-qr-bind'), path('dingtalk/qr/login/', views.DingTalkQRLoginView.as_view(), name='dingtalk-qr-login'), path('dingtalk/qr/bind//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('dingtalk/oauth/login/', views.DingTalkOAuthLoginView.as_view(), name='dingtalk-oauth-login'), + path('dingtalk/oauth/login/callback/', views.DingTalkOAuthLoginCallbackView.as_view(), name='dingtalk-oauth-login-callback'), path('feishu/bind/start/', views.FeiShuEnableStartView.as_view(), name='feishu-bind-start'), path('feishu/qr/bind/', views.FeiShuQRBindView.as_view(), name='feishu-qr-bind'), diff --git a/apps/authentication/views/dingtalk.py b/apps/authentication/views/dingtalk.py index e42e7ff81..b4fd2cede 100644 --- a/apps/authentication/views/dingtalk.py +++ b/apps/authentication/views/dingtalk.py @@ -28,7 +28,7 @@ logger = get_logger(__file__) DINGTALK_STATE_SESSION_KEY = '_dingtalk_state' -class DingTalkQRMixin(PermissionsMixin, View): +class DingTalkBaseMixin(PermissionsMixin, View): def dispatch(self, request, *args, **kwargs): try: return super().dispatch(request, *args, **kwargs) @@ -54,20 +54,6 @@ class DingTalkQRMixin(PermissionsMixin, View): 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[DINGTALK_STATE_SESSION_KEY] = state - - params = { - 'appid': settings.DINGTALK_APPKEY, - 'response_type': 'code', - 'scope': 'snsapi_login', - 'state': state, - 'redirect_uri': redirect_uri, - } - url = URL.QR_CONNECT + '?' + urlencode(params) - return url - @staticmethod def get_success_response(redirect_url, title, msg): message_data = { @@ -94,6 +80,42 @@ class DingTalkQRMixin(PermissionsMixin, View): return response +class DingTalkQRMixin(DingTalkBaseMixin, View): + + def get_qr_url(self, redirect_uri): + state = random_string(16) + self.request.session[DINGTALK_STATE_SESSION_KEY] = state + + params = { + 'appid': settings.DINGTALK_APPKEY, + 'response_type': 'code', + 'scope': 'snsapi_login', + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.QR_CONNECT + '?' + urlencode(params) + return url + + +class DingTalkOAuthMixin(DingTalkBaseMixin, View): + + def get_oauth_url(self, redirect_uri): + if not settings.AUTH_DINGTALK: + return reverse('authentication:login') + state = random_string(16) + self.request.session[DINGTALK_STATE_SESSION_KEY] = state + + params = { + 'appid': settings.DINGTALK_APPKEY, + 'response_type': 'code', + 'scope': 'snsapi_auth', + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.OAUTH_CONNECT + '?' + urlencode(params) + return url + + class DingTalkQRBindView(DingTalkQRMixin, View): permission_classes = (IsAuthenticated,) @@ -230,3 +252,57 @@ class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View): return response return self.redirect_to_guard_view() + + +class DingTalkOAuthLoginView(DingTalkOAuthMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + redirect_url = request.GET.get('redirect_url') + + redirect_uri = reverse('authentication:dingtalk-oauth-login-callback', external=True) + redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) + + url = self.get_oauth_url(redirect_uri) + return HttpResponseRedirect(url) + + +class DingTalkOAuthLoginCallbackView(AuthMixin, DingTalkOAuthMixin, 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) + + dingtalk = DingTalk( + appid=settings.DINGTALK_APPKEY, + appsecret=settings.DINGTALK_APPSECRET, + agentid=settings.DINGTALK_AGENTID + ) + userid = dingtalk.get_userid_by_code(code) + if not userid: + # 正常流程不会出这个错误,hack 行为 + msg = _('Failed to get user from DingTalk') + response = self.get_failed_response(login_url, title=msg, msg=msg) + return response + + user = get_object_or_none(User, dingtalk_id=userid) + if user is None: + title = _('DingTalk is not bound') + msg = _('Please login with a password and then bind the DingTalk') + response = self.get_failed_response(login_url, title=title, msg=msg) + return response + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_DINGTALK) + except errors.AuthFailedError as e: + self.set_login_failed_mark() + msg = e.msg + response = self.get_failed_response(login_url, title=msg, msg=msg) + return response + + return self.redirect_to_guard_view() \ No newline at end of file diff --git a/apps/authentication/views/wecom.py b/apps/authentication/views/wecom.py index 3ac1df686..87afd08ea 100644 --- a/apps/authentication/views/wecom.py +++ b/apps/authentication/views/wecom.py @@ -28,7 +28,7 @@ logger = get_logger(__file__) WECOM_STATE_SESSION_KEY = '_wecom_state' -class WeComQRMixin(PermissionsMixin, View): +class WeComBaseMixin(PermissionsMixin, View): def dispatch(self, request, *args, **kwargs): try: return super().dispatch(request, *args, **kwargs) @@ -54,19 +54,6 @@ class WeComQRMixin(PermissionsMixin, View): 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[WECOM_STATE_SESSION_KEY] = state - - params = { - 'appid': settings.WECOM_CORPID, - 'agentid': settings.WECOM_AGENTID, - 'state': state, - 'redirect_uri': redirect_uri, - } - url = URL.QR_CONNECT + '?' + urlencode(params) - return url - @staticmethod def get_success_response(redirect_url, title, msg): message_data = { @@ -93,6 +80,42 @@ class WeComQRMixin(PermissionsMixin, View): return response +class WeComQRMixin(WeComBaseMixin, View): + + def get_qr_url(self, redirect_uri): + state = random_string(16) + self.request.session[WECOM_STATE_SESSION_KEY] = state + + params = { + 'appid': settings.WECOM_CORPID, + 'agentid': settings.WECOM_AGENTID, + 'state': state, + 'redirect_uri': redirect_uri, + } + url = URL.QR_CONNECT + '?' + urlencode(params) + return url + + +class WeComOAuthMixin(WeComBaseMixin, View): + + def get_oauth_url(self, redirect_uri): + if not settings.AUTH_WECOM: + return reverse('authentication:login') + state = random_string(16) + self.request.session[WECOM_STATE_SESSION_KEY] = state + + params = { + 'appid': settings.WECOM_CORPID, + 'agentid': settings.WECOM_AGENTID, + 'state': state, + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': 'snsapi_base', + } + url = URL.OAUTH_CONNECT + '?' + urlencode(params) + '#wechat_redirect' + return url + + class WeComQRBindView(WeComQRMixin, View): permission_classes = (IsAuthenticated,) @@ -225,3 +248,57 @@ class WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, View): return response return self.redirect_to_guard_view() + + +class WeComOAuthLoginView(WeComOAuthMixin, View): + permission_classes = (AllowAny,) + + def get(self, request: HttpRequest): + redirect_url = request.GET.get('redirect_url') + + redirect_uri = reverse('authentication:wecom-oauth-login-callback', external=True) + redirect_uri += '?' + urlencode({'redirect_url': redirect_url}) + + url = self.get_oauth_url(redirect_uri) + return HttpResponseRedirect(url) + + +class WeComOAuthLoginCallbackView(AuthMixin, WeComOAuthMixin, 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) + + 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: + # 正常流程不会出这个错误,hack 行为 + msg = _('Failed to get user from WeCom') + response = self.get_failed_response(login_url, title=msg, msg=msg) + return response + + user = get_object_or_none(User, wecom_id=wecom_userid) + if user is None: + title = _('WeCom is not bound') + msg = _('Please login with a password and then bind the WeCom') + response = self.get_failed_response(login_url, title=title, msg=msg) + return response + + try: + self.check_oauth2_auth(user, settings.AUTH_BACKEND_WECOM) + except errors.AuthFailedError as e: + self.set_login_failed_mark() + msg = e.msg + response = self.get_failed_response(login_url, title=msg, msg=msg) + return response + + return self.redirect_to_guard_view() \ No newline at end of file diff --git a/apps/common/sdk/im/dingtalk/__init__.py b/apps/common/sdk/im/dingtalk/__init__.py index f18cd4b35..d41e73221 100644 --- a/apps/common/sdk/im/dingtalk/__init__.py +++ b/apps/common/sdk/im/dingtalk/__init__.py @@ -28,6 +28,7 @@ class ErrorCode: class URL: QR_CONNECT = 'https://oapi.dingtalk.com/connect/qrconnect' + OAUTH_CONNECT = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize' GET_USER_INFO_BY_CODE = 'https://oapi.dingtalk.com/sns/getuserinfo_bycode' GET_TOKEN = 'https://oapi.dingtalk.com/gettoken' SEND_MESSAGE_BY_TEMPLATE = 'https://oapi.dingtalk.com/topapi/message/corpconversation/sendbytemplate' diff --git a/apps/common/sdk/im/wecom/__init__.py b/apps/common/sdk/im/wecom/__init__.py index ceda292c7..bc925508e 100644 --- a/apps/common/sdk/im/wecom/__init__.py +++ b/apps/common/sdk/im/wecom/__init__.py @@ -19,6 +19,7 @@ class URL: GET_TOKEN = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken' SEND_MESSAGE = 'https://qyapi.weixin.qq.com/cgi-bin/message/send' QR_CONNECT = 'https://open.work.weixin.qq.com/wwopen/sso/qrConnect' + OAUTH_CONNECT = 'https://open.weixin.qq.com/connect/oauth2/authorize' # https://open.work.weixin.qq.com/api/doc/90000/90135/91437 GET_USER_ID_BY_CODE = 'https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo'