feat: 支持钉钉、飞书、企业微信扫码登录无用户时自动创建用户

pull/10409/head
jiangweidong 2023-04-28 14:01:44 +08:00 committed by Jiangjie.Bai
parent 7ff22cbc34
commit bda748d547
13 changed files with 221 additions and 135 deletions

View File

@ -23,7 +23,7 @@ from common.utils.random import random_string
from users.models import User
from users.views import UserVerifyPasswordView
from .mixins import METAMixin
from .mixins import METAMixin, QRLoginCallbackMixin
logger = get_logger(__file__)
@ -158,7 +158,7 @@ class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
appsecret=settings.DINGTALK_APPSECRET,
agentid=settings.DINGTALK_AGENTID
)
userid = dingtalk.get_userid_by_code(code)
userid, __ = dingtalk.get_user_id_by_code(code)
if not userid:
msg = _('DingTalk query user failed')
@ -214,45 +214,20 @@ class DingTalkQRLoginView(DingTalkQRMixin, METAMixin, View):
return HttpResponseRedirect(url)
class DingTalkQRLoginCallbackView(AuthMixin, DingTalkQRMixin, View):
class DingTalkQRLoginCallbackView(QRLoginCallbackMixin, AuthMixin, DingTalkQRMixin, 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')
CLIENT_INFO = (
DingTalk, {'appid': 'DINGTALK_APPKEY', 'appsecret': 'DINGTALK_APPSECRET', 'agentid': 'DINGTALK_AGENTID'}
)
USER_TYPE = 'dingtalk'
AUTH_BACKEND = 'AUTH_BACKEND_DINGTALK'
CREATE_USER_IF_NOT_EXIST = 'DINGTALK_CREATE_USER_IF_NOT_EXIST'
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()
MSG_CLIENT_ERR = _('DingTalk Error')
MSG_USER_NOT_BOUND_ERR = _('DingTalk is not bound')
MSG_USER_NEED_BOUND_WARNING = _('Please login with a password and then bind the DingTalk')
MSG_NOT_FOUND_USER_FROM_CLIENT_ERR = _('Failed to get user from DingTalk')
class DingTalkOAuthLoginView(DingTalkOAuthMixin, View):
@ -284,7 +259,7 @@ class DingTalkOAuthLoginCallbackView(AuthMixin, DingTalkOAuthMixin, View):
appsecret=settings.DINGTALK_APPSECRET,
agentid=settings.DINGTALK_AGENTID
)
userid = dingtalk.get_userid_by_code(code)
userid, __ = dingtalk.get_user_id_by_code(code)
if not userid:
# 正常流程不会出这个错误hack 行为
msg = _('Failed to get user from DingTalk')

View File

@ -9,7 +9,6 @@ from django.views import View
from rest_framework.exceptions import APIException
from rest_framework.permissions import AllowAny, IsAuthenticated
from authentication import errors
from authentication.const import ConfirmType
from authentication.mixins import AuthMixin
from authentication.notifications import OAuthBindMessage
@ -18,11 +17,12 @@ from common.permissions import UserConfirmation
from common.sdk.im.feishu import URL, FeiShu
from common.utils import FlashMessageUtil, get_logger
from common.utils.common import get_request_ip
from common.utils.django import get_object_or_none, reverse
from common.utils.django import reverse
from common.utils.random import random_string
from users.models import User
from users.views import UserVerifyPasswordView
from .mixins import QRLoginCallbackMixin
logger = get_logger(__file__)
FEISHU_STATE_SESSION_KEY = '_feishu_state'
@ -123,7 +123,7 @@ class FeiShuQRBindCallbackView(FeiShuQRMixin, View):
app_id=settings.FEISHU_APP_ID,
app_secret=settings.FEISHU_APP_SECRET
)
user_id = feishu.get_user_id_by_code(code)
user_id, __ = feishu.get_user_id_by_code(code)
if not user_id:
msg = _('FeiShu query user failed')
@ -176,41 +176,17 @@ class FeiShuQRLoginView(FeiShuQRMixin, View):
return HttpResponseRedirect(url)
class FeiShuQRLoginCallbackView(AuthMixin, FeiShuQRMixin, View):
class FeiShuQRLoginCallbackView(QRLoginCallbackMixin, 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')
CLIENT_INFO = (
FeiShu, {'app_id': 'FEISHU_APP_ID', 'app_secret': 'FEISHU_APP_SECRET'}
)
USER_TYPE = 'feishu'
AUTH_BACKEND = 'AUTH_BACKEND_FEISHU'
CREATE_USER_IF_NOT_EXIST = 'FEISHU_CREATE_USER_IF_NOT_EXIST'
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_response(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 FeiShu')
response = self.get_failed_response(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_response(login_url, title=msg, msg=msg)
return response
return self.redirect_to_guard_view()
MSG_CLIENT_ERR = _('FeiShu Error')
MSG_USER_NOT_BOUND_ERR = _('FeiShu is not bound')
MSG_USER_NEED_BOUND_WARNING = _('Please login with a password and then bind the FeiShu')
MSG_NOT_FOUND_USER_FROM_CLIENT_ERR = _('Failed to get user from FeiShu')

View File

@ -1,5 +1,20 @@
# -*- coding: utf-8 -*-
#
from functools import lru_cache
from rest_framework.request import Request
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.db.utils import IntegrityError
from authentication import errors
from users.models import User
from common.utils.django import reverse, get_object_or_none
from common.utils import get_logger
logger = get_logger(__file__)
class METAMixin:
def get_next_url_from_meta(self):
@ -10,3 +25,92 @@ class METAMixin:
if len(next_url_item) > 1:
next_url = next_url_item[-1]
return next_url
class Client:
get_user_id_by_code: callable
get_user_detail: callable
class QRLoginCallbackMixin:
verify_state: callable
get_verify_state_failed_response: callable
get_failed_response: callable
check_oauth2_auth: callable
set_login_failed_mark: callable
redirect_to_guard_view: callable
# 属性
_client: Client
CLIENT_INFO: tuple
USER_TYPE: str
AUTH_BACKEND: str
CREATE_USER_IF_NOT_EXIST: str
# 提示信息
MSG_CLIENT_ERR: str
MSG_USER_NOT_BOUND_ERR: str
MSG_USER_NEED_BOUND_WARNING: str
MSG_NOT_FOUND_USER_FROM_CLIENT_ERR: str
@property
@lru_cache(maxsize=1)
def client(self):
client_type, client_init = self.CLIENT_INFO
client_init = {k: getattr(settings, v) for k, v in client_init.items()}
return client_type(**client_init)
def create_user_if_not_exist(self, user_id, **kwargs):
user = None
if not getattr(settings, self.CREATE_USER_IF_NOT_EXIST):
title = self.MSG_CLIENT_ERR
msg = self.MSG_USER_NEED_BOUND_WARNING
return user, (title, msg)
user_attr = self.client.get_user_detail(user_id, **kwargs)
try:
user, create = User.objects.get_or_create(
username=user_attr['username'], defaults=user_attr
)
setattr(user, f'{self.USER_TYPE}_id', user_id)
if create:
setattr(user, 'source', self.USER_TYPE)
user.save()
except IntegrityError as err:
logger.error(f'{self.MSG_CLIENT_ERR}: create user error: {err}')
if user is None:
title = self.MSG_CLIENT_ERR
msg = _('If you have any question, please contact the administrator')
return user, (title, msg)
return user, None
def get(self, request: Request):
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)
user_id, other_info = self.client.get_user_id_by_code(code)
if not user_id:
# 正常流程不会出这个错误hack 行为
err = self.MSG_NOT_FOUND_USER_FROM_CLIENT_ERR
response = self.get_failed_response(login_url, title=err, msg=err)
return response
user = get_object_or_none(User, **{f'{self.USER_TYPE}_id': user_id})
if user is None:
user, err = self.create_user_if_not_exist(user_id, other_info=other_info)
if err is not None:
response = self.get_failed_response(login_url, title=err[0], msg=err[1])
return response
try:
self.check_oauth2_auth(user, getattr(settings, self.AUTH_BACKEND))
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()

View File

@ -22,7 +22,8 @@ from authentication import errors
from authentication.mixins import AuthMixin
from authentication.const import ConfirmType
from authentication.notifications import OAuthBindMessage
from .mixins import METAMixin
from .mixins import METAMixin, QRLoginCallbackMixin
logger = get_logger(__file__)
@ -208,45 +209,20 @@ class WeComQRLoginView(WeComQRMixin, METAMixin, View):
return HttpResponseRedirect(url)
class WeComQRLoginCallbackView(AuthMixin, WeComQRMixin, View):
class WeComQRLoginCallbackView(QRLoginCallbackMixin, AuthMixin, WeComQRMixin, 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')
CLIENT_INFO = (
WeCom, {'corpid': 'WECOM_CORPID', 'corpsecret': 'WECOM_SECRET', 'agentid': 'WECOM_AGENTID'}
)
USER_TYPE = 'wecom'
AUTH_BACKEND = 'AUTH_BACKEND_WECOM'
CREATE_USER_IF_NOT_EXIST = 'WECOM_CREATE_USER_IF_NOT_EXIST'
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()
MSG_CLIENT_ERR = _('WeCom Error')
MSG_USER_NOT_BOUND_ERR = _('WeCom is not bound')
MSG_USER_NEED_BOUND_WARNING = _('Please login with a password and then bind the WeCom')
MSG_NOT_FOUND_USER_FROM_CLIENT_ERR = _('Failed to get user from WeCom')
class WeComOAuthLoginView(WeComOAuthMixin, View):

View File

@ -5,6 +5,7 @@ import base64
from common.utils import get_logger
from common.sdk.im.utils import digest, as_request
from common.sdk.im.mixin import BaseRequest
from users.utils import construct_user_email
logger = get_logger(__file__)
@ -35,6 +36,7 @@ class URL:
SEND_MESSAGE = 'https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2'
GET_SEND_MSG_PROGRESS = 'https://oapi.dingtalk.com/topapi/message/corpconversation/getsendprogress'
GET_USERID_BY_UNIONID = 'https://oapi.dingtalk.com/topapi/user/getbyunionid'
GET_USER_INFO_BY_USER_ID = 'https://oapi.dingtalk.com/topapi/v2/user/get'
class DingTalkRequests(BaseRequest):
@ -129,11 +131,11 @@ class DingTalk:
data = self._request.post(URL.GET_USER_INFO_BY_CODE, json=body, with_sign=True)
return data['user_info']
def get_userid_by_code(self, code):
def get_user_id_by_code(self, code):
user_info = self.get_userinfo_bycode(code)
unionid = user_info['unionid']
userid = self.get_userid_by_unionid(unionid)
return userid
return userid, None
def get_userid_by_unionid(self, unionid):
body = {
@ -195,3 +197,18 @@ class DingTalk:
data = self._request.post(URL.GET_SEND_MSG_PROGRESS, json=body, with_token=True)
return data
def get_user_detail(self, user_id, **kwargs):
# https://open.dingtalk.com/document/orgapp/query-user-details
body = {'userid': user_id}
data = self._request.post(
URL.GET_USER_INFO_BY_USER_ID, json=body, with_token=True
)
data = data['result']
username = user_id
name = data.get('name', username)
email = data.get('email') or data.get('org_email')
email = construct_user_email(username, email)
return {
'username': username, 'name': name, 'email': email
}

View File

@ -1,9 +1,9 @@
import json
from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import APIException
from django.conf import settings
from users.utils import construct_user_email
from common.utils.common import get_logger
from common.sdk.im.utils import digest
from common.sdk.im.mixin import RequestMixin, BaseRequest
@ -30,13 +30,16 @@ class URL:
return f'{self.host}/open-apis/auth/v3/tenant_access_token/internal/'
@property
def get_user_info_by_code(self):
def get_userinfo_by_code(self):
return f'{self.host}/open-apis/authen/v1/access_token'
@property
def send_message(self):
return f'{self.host}/open-apis/im/v1/messages'
def get_user_detail(self, user_id):
return f'{self.host}/open-apis/contact/v3/users/{user_id}'
class ErrorCode:
INVALID_APP_ACCESS_TOKEN = 99991664
@ -103,10 +106,10 @@ class FeiShu(RequestMixin):
'code': code
}
data = self._requests.post(URL().get_user_info_by_code, json=body, check_errcode_is_0=False)
data = self._requests.post(URL().get_userinfo_by_code, json=body, check_errcode_is_0=False)
self._requests.check_errcode_is_0(data)
return data['data']['user_id']
return data['data']['user_id'], data['data']
def send_text(self, user_ids, msg):
params = {
@ -130,3 +133,15 @@ class FeiShu(RequestMixin):
logger.exception(e)
invalid_users.append(user_id)
return invalid_users
@staticmethod
def get_user_detail(user_id, **kwargs):
# get_user_id_by_code 已经返回个人信息,这里直接解析
data = kwargs['other_info']
username = user_id
name = data.get('name', username)
email = data.get('email') or data.get('enterprise_email')
email = construct_user_email(username, email)
return {
'username': username, 'name': name, 'email': email
}

View File

@ -3,8 +3,9 @@ from typing import Iterable, AnyStr
from django.utils.translation import ugettext_lazy as _
from rest_framework.exceptions import APIException
from users.utils import construct_user_email
from common.utils.common import get_logger
from common.sdk.im.utils import digest, DictWrapper, update_values, set_default
from common.sdk.im.utils import digest, update_values
from common.sdk.im.mixin import RequestMixin, BaseRequest
logger = get_logger(__name__)
@ -151,10 +152,7 @@ class WeCom(RequestMixin):
def get_user_id_by_code(self, code):
# # https://open.work.weixin.qq.com/api/doc/90000/90135/91437
params = {
'code': code,
}
params = {'code': code}
data = self._requests.get(URL.GET_USER_ID_BY_CODE, params=params, check_errcode_is_0=False)
errcode = data['errcode']
@ -175,12 +173,15 @@ class WeCom(RequestMixin):
logger.error(f'WeCom response 200 but get field from json error: fields=UserId|OpenId')
raise WeComError
def get_user_detail(self, id):
def get_user_detail(self, user_id, **kwargs):
# https://open.work.weixin.qq.com/api/doc/90000/90135/90196
params = {
'userid': id,
params = {'userid': user_id}
data = self._requests.get(URL.GET_USER_DETAIL, params)
username = data.get('userid')
name = data.get('name', username)
email = data.get('email') or data.get('biz_mail')
email = construct_user_email(username, email)
return {
'username': username, 'name': name, 'email': email
}
data = self._requests.get(URL.GET_USER_DETAIL, params)
return data

View File

@ -365,18 +365,21 @@ class Config(dict):
'WECOM_CORPID': '',
'WECOM_AGENTID': '',
'WECOM_SECRET': '',
'WECOM_CREATE_USER_IF_NOT_EXIST': False,
# 钉钉
'AUTH_DINGTALK': False,
'DINGTALK_AGENTID': '',
'DINGTALK_APPKEY': '',
'DINGTALK_APPSECRET': '',
'DINGTALK_CREATE_USER_IF_NOT_EXIST': False,
# 飞书
'AUTH_FEISHU': False,
'FEISHU_APP_ID': '',
'FEISHU_APP_SECRET': '',
'FEISHU_VERSION': 'feishu',
'FEISHU_CREATE_USER_IF_NOT_EXIST': False,
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS / SAML2
'LOGIN_REDIRECT_MSG_ENABLED': True,

View File

@ -13,3 +13,6 @@ class DingTalkSettingSerializer(serializers.Serializer):
DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label='AppKey')
DINGTALK_APPSECRET = EncryptedField(max_length=256, required=False, label='AppSecret')
AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth'))
DINGTALK_CREATE_USER_IF_NOT_EXIST = serializers.BooleanField(
default=False, label=_('Create user if not')
)

View File

@ -19,3 +19,6 @@ class FeiShuSettingSerializer(serializers.Serializer):
FEISHU_VERSION = serializers.ChoiceField(
choices=VERSION_CHOICES, default='feishu', label=_('Version')
)
FEISHU_CREATE_USER_IF_NOT_EXIST = serializers.BooleanField(
default=False, label=_('Create user if not')
)

View File

@ -13,3 +13,6 @@ class WeComSettingSerializer(serializers.Serializer):
WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label='agentid')
WECOM_SECRET = EncryptedField(max_length=256, required=False, label='secret')
AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth'))
WECOM_CREATE_USER_IF_NOT_EXIST = serializers.BooleanField(
default=False, label=_('Create user if not')
)

View File

@ -13,6 +13,6 @@ class Migration(migrations.Migration):
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'), ('custom', 'Custom')], default='local', max_length=30, verbose_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'), ('custom', 'Custom')], default='local', max_length=30, verbose_name='Source'),
),
]

View File

@ -677,14 +677,15 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
cas = 'cas', 'CAS'
saml2 = 'saml2', 'SAML2'
oauth2 = 'oauth2', 'OAuth2'
wecom = 'wecom', _('WeCom')
dingtalk = 'dingtalk', _('DingTalk')
feishu = 'feishu', _('FeiShu')
custom = 'custom', 'Custom'
SOURCE_BACKEND_MAPPING = {
Source.local: [
settings.AUTH_BACKEND_MODEL,
settings.AUTH_BACKEND_PUBKEY,
settings.AUTH_BACKEND_WECOM,
settings.AUTH_BACKEND_DINGTALK,
],
Source.ldap: [
settings.AUTH_BACKEND_LDAP
@ -705,6 +706,15 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
Source.oauth2: [
settings.AUTH_BACKEND_OAUTH2
],
Source.wecom: [
settings.AUTH_BACKEND_WECOM
],
Source.feishu: [
settings.AUTH_BACKEND_FEISHU
],
Source.dingtalk: [
settings.AUTH_BACKEND_DINGTALK
],
Source.custom: [
settings.AUTH_BACKEND_CUSTOM
]