mirror of https://github.com/jumpserver/jumpserver
feat: 添加企业微信,钉钉扫码登录
parent
340547c889
commit
c16319ec48
|
@ -57,6 +57,8 @@ class AuthBackendLabelMapping(LazyObject):
|
|||
backend_label_mapping[settings.AUTH_BACKEND_PUBKEY] = _('SSH Key')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_MODEL] = _('Password')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_SSO] = _('SSO')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _('WeCom')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _('DingTalk')
|
||||
return backend_label_mapping
|
||||
|
||||
def _setup(self):
|
||||
|
|
|
@ -7,3 +7,6 @@ from .mfa import *
|
|||
from .access_key import *
|
||||
from .login_confirm import *
|
||||
from .sso import *
|
||||
from .wecom import *
|
||||
from .dingtalk import *
|
||||
from .password import *
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
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 DingTalkQRUnBindBase(APIView):
|
||||
user: User
|
||||
|
||||
def post(self, request: Request, **kwargs):
|
||||
user = self.user
|
||||
|
||||
if not user.dingtalk_id:
|
||||
raise errors.DingTalkNotBound
|
||||
|
||||
user.dingtalk_id = ''
|
||||
user.save()
|
||||
return Response()
|
||||
|
||||
|
||||
class DingTalkQRUnBindForUserApi(RoleUserMixin, DingTalkQRUnBindBase):
|
||||
permission_classes = (IsAuthPasswdTimeValid,)
|
||||
|
||||
|
||||
class DingTalkQRUnBindForAdminApi(RoleAdminMixin, DingTalkQRUnBindBase):
|
||||
user_id_url_kwarg = 'user_id'
|
||||
permission_classes = (IsOrgAdmin,)
|
|
@ -0,0 +1,26 @@
|
|||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentication.serializers import PasswordVerifySerializer
|
||||
from common.permissions import IsValidUser
|
||||
from authentication.mixins import authenticate
|
||||
from authentication.errors import PasswdInvalid
|
||||
from authentication.mixins import AuthMixin
|
||||
|
||||
|
||||
class UserPasswordVerifyApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (IsValidUser,)
|
||||
serializer_class = PasswordVerifySerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
password = serializer.validated_data['password']
|
||||
user = self.request.user
|
||||
|
||||
user = authenticate(request=request, username=user.username, password=password)
|
||||
if not user:
|
||||
raise PasswdInvalid
|
||||
|
||||
self.set_passwd_verify_on_session(user)
|
||||
return Response()
|
|
@ -0,0 +1,35 @@
|
|||
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 WeComQRUnBindBase(APIView):
|
||||
user: User
|
||||
|
||||
def post(self, request: Request, **kwargs):
|
||||
user = self.user
|
||||
|
||||
if not user.wecom_id:
|
||||
raise errors.WeComNotBound
|
||||
|
||||
user.wecom_id = ''
|
||||
user.save()
|
||||
return Response()
|
||||
|
||||
|
||||
class WeComQRUnBindForUserApi(RoleUserMixin, WeComQRUnBindBase):
|
||||
permission_classes = (IsAuthPasswdTimeValid,)
|
||||
|
||||
|
||||
class WeComQRUnBindForAdminApi(RoleAdminMixin, WeComQRUnBindBase):
|
||||
user_id_url_kwarg = 'user_id'
|
||||
permission_classes = (IsOrgAdmin,)
|
|
@ -205,3 +205,21 @@ class SSOAuthentication(ModelBackend):
|
|||
|
||||
def authenticate(self, request, sso_token=None, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class WeComAuthentication(ModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class DingTalkAuthentication(ModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
|
|
@ -19,6 +19,7 @@ reason_user_inactive = 'user_inactive'
|
|||
reason_user_expired = 'user_expired'
|
||||
reason_backend_not_match = 'backend_not_match'
|
||||
reason_acl_not_allow = 'acl_not_allow'
|
||||
only_local_users_are_allowed = 'only_local_users_are_allowed'
|
||||
|
||||
reason_choices = {
|
||||
reason_password_failed: _('Username/password check failed'),
|
||||
|
@ -32,6 +33,7 @@ reason_choices = {
|
|||
reason_user_expired: _("This account is expired"),
|
||||
reason_backend_not_match: _("Auth backend not match"),
|
||||
reason_acl_not_allow: _("ACL is not allowed"),
|
||||
only_local_users_are_allowed: _("Only local users are allowed")
|
||||
}
|
||||
old_reason_choices = {
|
||||
'0': '-',
|
||||
|
@ -291,3 +293,28 @@ class PasswordRequireResetError(JMSException):
|
|||
def __init__(self, url, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.url = url
|
||||
|
||||
|
||||
class WeComCodeInvalid(JMSException):
|
||||
default_code = 'wecom_code_invalid'
|
||||
default_detail = 'Code invalid, can not get user info'
|
||||
|
||||
|
||||
class WeComBindAlready(JMSException):
|
||||
default_code = 'wecom_bind_already'
|
||||
default_detail = 'WeCom already binded'
|
||||
|
||||
|
||||
class WeComNotBound(JMSException):
|
||||
default_code = 'wecom_not_bound'
|
||||
default_detail = 'WeCom is not bound'
|
||||
|
||||
|
||||
class DingTalkNotBound(JMSException):
|
||||
default_code = 'dingtalk_not_bound'
|
||||
default_detail = 'DingTalk is not bound'
|
||||
|
||||
|
||||
class PasswdInvalid(JMSException):
|
||||
default_code = 'passwd_invalid'
|
||||
default_detail = _('Your password is invalid')
|
||||
|
|
|
@ -5,6 +5,7 @@ from urllib.parse import urlencode
|
|||
from functools import partial
|
||||
import time
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.utils.translation import ugettext as _
|
||||
|
@ -12,7 +13,7 @@ from django.contrib.auth import (
|
|||
BACKEND_SESSION_KEY, _get_backends,
|
||||
PermissionDenied, user_login_failed, _clean_credentials
|
||||
)
|
||||
from django.shortcuts import reverse
|
||||
from django.shortcuts import reverse, redirect
|
||||
|
||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from users.models import User
|
||||
|
@ -82,6 +83,8 @@ class AuthMixin:
|
|||
request = None
|
||||
partial_credential_error = None
|
||||
|
||||
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
||||
|
||||
def get_user_from_session(self):
|
||||
if self.request.session.is_empty():
|
||||
raise errors.SessionEmptyError()
|
||||
|
@ -110,11 +113,7 @@ class AuthMixin:
|
|||
ip = ip or get_request_ip(self.request)
|
||||
return ip
|
||||
|
||||
def check_is_block(self, raise_exception=True):
|
||||
if hasattr(self.request, 'data'):
|
||||
username = self.request.data.get("username")
|
||||
else:
|
||||
username = self.request.POST.get("username")
|
||||
def _check_is_block(self, username, raise_exception=True):
|
||||
ip = self.get_request_ip()
|
||||
if LoginBlockUtil(username, ip).is_block():
|
||||
logger.warn('Ip was blocked' + ': ' + username + ':' + ip)
|
||||
|
@ -124,6 +123,13 @@ class AuthMixin:
|
|||
else:
|
||||
return exception
|
||||
|
||||
def check_is_block(self, raise_exception=True):
|
||||
if hasattr(self.request, 'data'):
|
||||
username = self.request.data.get("username")
|
||||
else:
|
||||
username = self.request.POST.get("username")
|
||||
self._check_is_block(username, raise_exception)
|
||||
|
||||
def decrypt_passwd(self, raw_passwd):
|
||||
# 获取解密密钥,对密码进行解密
|
||||
rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY)
|
||||
|
@ -140,6 +146,9 @@ class AuthMixin:
|
|||
def raise_credential_error(self, error):
|
||||
raise self.partial_credential_error(error=error)
|
||||
|
||||
def _set_partial_credential_error(self, username, ip, request):
|
||||
self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request)
|
||||
|
||||
def get_auth_data(self, decrypt_passwd=False):
|
||||
request = self.request
|
||||
if hasattr(request, 'data'):
|
||||
|
@ -151,7 +160,7 @@ class AuthMixin:
|
|||
username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='')
|
||||
password = password + challenge.strip()
|
||||
ip = self.get_request_ip()
|
||||
self.partial_credential_error = partial(errors.CredentialError, username=username, ip=ip, request=request)
|
||||
self._set_partial_credential_error(username=username, ip=ip, request=request)
|
||||
|
||||
if decrypt_passwd:
|
||||
password = self.decrypt_passwd(password)
|
||||
|
@ -184,6 +193,21 @@ class AuthMixin:
|
|||
if not is_allowed:
|
||||
raise errors.LoginIPNotAllowed(username=user.username, request=self.request)
|
||||
|
||||
def set_login_failed_mark(self):
|
||||
ip = self.get_request_ip()
|
||||
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
||||
|
||||
def set_passwd_verify_on_session(self, user: User):
|
||||
self.request.session['user_id'] = str(user.id)
|
||||
self.request.session['auth_password'] = 1
|
||||
self.request.session['auth_password_expired_at'] = time.time() + settings.AUTH_EXPIRED_SECONDS
|
||||
|
||||
def check_is_need_captcha(self):
|
||||
# 最近有登录失败时需要填写验证码
|
||||
ip = get_request_ip(self.request)
|
||||
need = cache.get(self.key_prefix_captcha.format(ip))
|
||||
return need
|
||||
|
||||
def check_user_auth(self, decrypt_passwd=False):
|
||||
self.check_is_block()
|
||||
request = self.request
|
||||
|
@ -204,6 +228,27 @@ class AuthMixin:
|
|||
request.session['auth_backend'] = getattr(user, 'backend', settings.AUTH_BACKEND_MODEL)
|
||||
return user
|
||||
|
||||
def _check_is_local_user(self, user: User):
|
||||
if user.source != User.Source.local:
|
||||
raise self.raise_credential_error(error=errors.only_local_users_are_allowed)
|
||||
|
||||
def check_oauth2_auth(self, user: User, auth_backend):
|
||||
ip = self.get_request_ip()
|
||||
request = self.request
|
||||
|
||||
self._set_partial_credential_error(user.username, ip, request)
|
||||
self._check_is_local_user(user)
|
||||
self._check_is_block(user.username)
|
||||
self._check_login_acl(user, ip)
|
||||
|
||||
LoginBlockUtil(user.username, ip).clean_failed_count()
|
||||
MFABlockUtils(user.username, ip).clean_failed_count()
|
||||
|
||||
request.session['auth_password'] = 1
|
||||
request.session['user_id'] = str(user.id)
|
||||
request.session['auth_backend'] = auth_backend
|
||||
return user
|
||||
|
||||
@classmethod
|
||||
def generate_reset_password_url_with_flash_msg(cls, user, message):
|
||||
reset_passwd_url = reverse('authentication:reset-password')
|
||||
|
@ -354,3 +399,10 @@ class AuthMixin:
|
|||
sender=self.__class__, username=username,
|
||||
request=self.request, reason=reason
|
||||
)
|
||||
|
||||
def redirect_to_guard_view(self):
|
||||
guard_url = reverse('authentication:login-guard')
|
||||
args = self.request.META.get('QUERY_STRING', '')
|
||||
if args:
|
||||
guard_url = "%s?%s" % (guard_url, args)
|
||||
return redirect(guard_url)
|
||||
|
|
|
@ -10,13 +10,14 @@ from applications.models import Application
|
|||
from users.serializers import UserProfileSerializer
|
||||
from assets.serializers import ProtocolsField
|
||||
from perms.serializers.asset.permission import ActionsField
|
||||
from .models import AccessKey, LoginConfirmSetting, SSOToken
|
||||
from .models import AccessKey, LoginConfirmSetting
|
||||
|
||||
|
||||
__all__ = [
|
||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer'
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer',
|
||||
'PasswordVerifySerializer',
|
||||
]
|
||||
|
||||
|
||||
|
@ -31,6 +32,10 @@ class OtpVerifySerializer(serializers.Serializer):
|
|||
code = serializers.CharField(max_length=6, min_length=6)
|
||||
|
||||
|
||||
class PasswordVerifySerializer(serializers.Serializer):
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class BearerTokenSerializer(serializers.Serializer):
|
||||
username = serializers.CharField(allow_null=True, required=False, write_only=True)
|
||||
password = serializers.CharField(write_only=True, allow_null=True,
|
||||
|
|
|
@ -117,6 +117,15 @@
|
|||
float: right;
|
||||
margin: 10px 10px 0 0;
|
||||
}
|
||||
.more-login-item {
|
||||
border-right: 1px dashed #dedede;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.more-login-item:last-child {
|
||||
border: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
@ -182,10 +191,10 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
{% if AUTH_OPENID or AUTH_CAS %}
|
||||
{% if AUTH_OPENID or AUTH_CAS or AUTH_WECOM or AUTH_DINGTALK %}
|
||||
<div class="hr-line-dashed"></div>
|
||||
<div style="display: inline-block; float: left">
|
||||
<b class="text-muted text-left" style="margin-right: 10px">{% trans "More login options" %}</b>
|
||||
<b class="text-muted text-left" >{% trans "More login options" %}</b>
|
||||
{% if AUTH_OPENID %}
|
||||
<a href="{% url 'authentication:openid:login' %}" class="more-login-item">
|
||||
<i class="fa fa-openid"></i> {% trans 'OpenID' %}
|
||||
|
@ -196,6 +205,17 @@
|
|||
<i class="fa"><img src="{{ LOGIN_CAS_LOGO_URL }}" height="13" width="13"></i> {% trans 'CAS' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_WECOM %}
|
||||
<a href="{% url 'authentication:wecom-qr-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_WECOM_LOGO_URL }}" height="13" width="13"></i> {% trans 'WeCom' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if AUTH_DINGTALK %}
|
||||
<a href="{% url 'authentication:dingtalk-qr-login' %}" class="more-login-item">
|
||||
<i class="fa"><img src="{{ LOGIN_DINGTALK_LOGO_URL }}" height="13" width="13"></i> {% trans 'DingTalk' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center" style="display: inline-block;">
|
||||
|
|
|
@ -14,10 +14,17 @@ router.register('connection-token', api.UserConnectionTokenViewSet, 'connection-
|
|||
|
||||
urlpatterns = [
|
||||
# path('token/', api.UserToken.as_view(), name='user-token'),
|
||||
path('wecom/qr/unbind/', api.WeComQRUnBindForUserApi.as_view(), name='wecom-qr-unbind'),
|
||||
path('wecom/qr/unbind/<uuid:user_id>/', api.WeComQRUnBindForAdminApi.as_view(), name='wecom-qr-unbind-for-admin'),
|
||||
|
||||
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('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'),
|
||||
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
|
||||
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
|
||||
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
|
||||
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
|
||||
]
|
||||
|
|
|
@ -21,6 +21,22 @@ urlpatterns = [
|
|||
path('password/reset/', users_view.UserResetPasswordView.as_view(), name='reset-password'),
|
||||
path('password/verify/', users_view.UserVerifyPasswordView.as_view(), name='user-verify-password'),
|
||||
|
||||
path('wecom/bind/success-flash-msg/', views.FlashWeComBindSucceedMsgView.as_view(), name='wecom-bind-success-flash-msg'),
|
||||
path('wecom/bind/failed-flash-msg/', views.FlashWeComBindFailedMsgView.as_view(), name='wecom-bind-failed-flash-msg'),
|
||||
path('wecom/bind/start/', views.WeComEnableStartView.as_view(), name='wecom-bind-start'),
|
||||
path('wecom/qr/bind/', views.WeComQRBindView.as_view(), name='wecom-qr-bind'),
|
||||
path('wecom/qr/login/', views.WeComQRLoginView.as_view(), name='wecom-qr-login'),
|
||||
path('wecom/qr/bind/<uuid:user_id>/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('dingtalk/bind/success-flash-msg/', views.FlashDingTalkBindSucceedMsgView.as_view(), name='dingtalk-bind-success-flash-msg'),
|
||||
path('dingtalk/bind/failed-flash-msg/', views.FlashDingTalkBindFailedMsgView.as_view(), name='dingtalk-bind-failed-flash-msg'),
|
||||
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/<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'),
|
||||
|
||||
# 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'),
|
||||
|
|
|
@ -2,3 +2,5 @@
|
|||
#
|
||||
from .login import *
|
||||
from .mfa import *
|
||||
from .wecom import *
|
||||
from .dingtalk import *
|
||||
|
|
|
@ -0,0 +1,243 @@
|
|||
import urllib
|
||||
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext 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 rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.utils import is_auth_password_time_valid
|
||||
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.message.backends.dingtalk import URL
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
from common.message.backends.dingtalk import DingTalk
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
DINGTALK_STATE_SESSION_KEY = '_dingtalk_state'
|
||||
|
||||
|
||||
class DingTalkQRMixin(PermissionsMixin, View):
|
||||
def verify_state(self):
|
||||
state = self.request.GET.get('state')
|
||||
session_state = self.request.session.get(DINGTALK_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[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 + '?' + urllib.parse.urlencode(params)
|
||||
return url
|
||||
|
||||
def get_success_reponse(self, redirect_url, title, msg):
|
||||
ok_flash_msg_url = reverse('authentication:dingtalk-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:dingtalk-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 = _('DingTalk is already bound')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class DingTalkQRBindView(DingTalkQRMixin, 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:dingtalk-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
|
||||
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class DingTalkQRBindCallbackView(DingTalkQRMixin, View):
|
||||
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'DingTalkQR bind callback error, user_id invalid: user_id={user_id}')
|
||||
msg = _('Invalid user_id')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
if user.dingtalk_id:
|
||||
response = self.get_already_bound_response(redirect_url)
|
||||
return response
|
||||
|
||||
dingtalk = DingTalk(
|
||||
appid=settings.DINGTALK_APPKEY,
|
||||
appsecret=settings.DINGTALK_APPSECRET,
|
||||
agentid=settings.DINGTALK_AGENTID
|
||||
)
|
||||
userid = dingtalk.get_userid_by_code(code)
|
||||
|
||||
if not userid:
|
||||
msg = _('DingTalk query user failed')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
user.dingtalk_id = userid
|
||||
user.save()
|
||||
|
||||
msg = _('Binding DingTalk successfully')
|
||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class DingTalkEnableStartView(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:dingtalk-qr-bind')
|
||||
|
||||
success_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url or referer
|
||||
})
|
||||
|
||||
return success_url
|
||||
|
||||
|
||||
class DingTalkQRLoginView(DingTalkQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:dingtalk-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 DingTalkQRLoginCallbackView(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')
|
||||
|
||||
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_reponse(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 WoCom')
|
||||
response = self.get_failed_reponse(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_reponse(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashDingTalkBindSucceedMsgView(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 DingTalk successfully'),
|
||||
'messages': msg or _('Binding DingTalk 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 FlashDingTalkBindFailedMsgView(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 DingTalk failed'),
|
||||
'messages': msg or _('Binding DingTalk failed'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
|
@ -4,7 +4,6 @@
|
|||
from __future__ import unicode_literals
|
||||
import os
|
||||
import datetime
|
||||
from django.core.cache import cache
|
||||
from django.contrib.auth import login as auth_login, logout as auth_logout
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import reverse, redirect
|
||||
|
@ -38,7 +37,6 @@ __all__ = [
|
|||
@method_decorator(csrf_protect, name='dispatch')
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class UserLoginView(mixins.AuthMixin, FormView):
|
||||
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
||||
redirect_field_name = 'next'
|
||||
template_name = 'authentication/login.html'
|
||||
|
||||
|
@ -90,10 +88,9 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||
try:
|
||||
self.check_user_auth(decrypt_passwd=True)
|
||||
except errors.AuthFailedError as e:
|
||||
e = self.check_is_block(raise_exception=False) or e
|
||||
form.add_error(None, e.msg)
|
||||
ip = self.get_request_ip()
|
||||
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
||||
self.set_login_failed_mark()
|
||||
|
||||
form_cls = get_user_login_form_cls(captcha=True)
|
||||
new_form = form_cls(data=form.data)
|
||||
new_form._errors = form.errors
|
||||
|
@ -105,16 +102,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||
self.clear_rsa_key()
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
def redirect_to_guard_view(self):
|
||||
guard_url = reverse('authentication:login-guard')
|
||||
args = self.request.META.get('QUERY_STRING', '')
|
||||
if args:
|
||||
guard_url = "%s?%s" % (guard_url, args)
|
||||
return redirect(guard_url)
|
||||
|
||||
def get_form_class(self):
|
||||
ip = get_request_ip(self.request)
|
||||
if cache.get(self.key_prefix_captcha.format(ip)):
|
||||
if self.check_is_need_captcha():
|
||||
return get_user_login_form_cls(captcha=True)
|
||||
else:
|
||||
return get_user_login_form_cls()
|
||||
|
@ -142,6 +131,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
|||
'demo_mode': os.environ.get("DEMO_MODE"),
|
||||
'AUTH_OPENID': settings.AUTH_OPENID,
|
||||
'AUTH_CAS': settings.AUTH_CAS,
|
||||
'AUTH_WECOM': settings.AUTH_WECOM,
|
||||
'AUTH_DINGTALK': settings.AUTH_DINGTALK,
|
||||
'rsa_public_key': rsa_public_key,
|
||||
'forgot_password_url': forgot_password_url
|
||||
}
|
||||
|
|
|
@ -0,0 +1,241 @@
|
|||
import urllib
|
||||
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext 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 rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
|
||||
from users.views import UserVerifyPasswordView
|
||||
from users.utils import is_auth_password_time_valid
|
||||
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.message.backends.wecom import URL
|
||||
from common.message.backends.wecom import WeCom
|
||||
from common.mixins.views import PermissionsMixin
|
||||
from authentication import errors
|
||||
from authentication.mixins import AuthMixin
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
WECOM_STATE_SESSION_KEY = '_wecom_state'
|
||||
|
||||
|
||||
class WeComQRMixin(PermissionsMixin, View):
|
||||
def verify_state(self):
|
||||
state = self.request.GET.get('state')
|
||||
session_state = self.request.session.get(WECOM_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[WECOM_STATE_SESSION_KEY] = state
|
||||
|
||||
params = {
|
||||
'appid': settings.WECOM_CORPID,
|
||||
'agentid': settings.WECOM_AGENTID,
|
||||
'state': state,
|
||||
'redirect_uri': redirect_uri,
|
||||
}
|
||||
url = URL.QR_CONNECT + '?' + urllib.parse.urlencode(params)
|
||||
return url
|
||||
|
||||
def get_success_reponse(self, redirect_url, title, msg):
|
||||
ok_flash_msg_url = reverse('authentication:wecom-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:wecom-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 = _('WeCom is already bound')
|
||||
response = self.get_failed_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class WeComQRBindView(WeComQRMixin, 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:wecom-qr-bind-callback', kwargs={'user_id': user.id}, external=True)
|
||||
redirect_uri += '?' + urllib.parse.urlencode({'redirect_url': redirect_url})
|
||||
|
||||
url = self.get_qr_url(redirect_uri)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
class WeComQRBindCallbackView(WeComQRMixin, View):
|
||||
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_reponse(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_CORPSECRET,
|
||||
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_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
user.wecom_id = wecom_userid
|
||||
user.save()
|
||||
|
||||
msg = _('Binding WeCom successfully')
|
||||
response = self.get_success_reponse(redirect_url, msg, msg)
|
||||
return response
|
||||
|
||||
|
||||
class WeComEnableStartView(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:wecom-qr-bind')
|
||||
|
||||
success_url += '?' + urllib.parse.urlencode({
|
||||
'redirect_url': redirect_url or referer
|
||||
})
|
||||
|
||||
return success_url
|
||||
|
||||
|
||||
class WeComQRLoginView(WeComQRMixin, View):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
redirect_url = request.GET.get('redirect_url')
|
||||
|
||||
redirect_uri = reverse('authentication:wecom-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 WeComQRLoginCallbackView(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')
|
||||
|
||||
if not self.verify_state():
|
||||
return self.get_verify_state_failed_response(redirect_url)
|
||||
|
||||
wecom = WeCom(
|
||||
corpid=settings.WECOM_CORPID,
|
||||
corpsecret=settings.WECOM_CORPSECRET,
|
||||
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_reponse(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 WoCom')
|
||||
response = self.get_failed_reponse(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_reponse(login_url, title=msg, msg=msg)
|
||||
return response
|
||||
|
||||
return self.redirect_to_guard_view()
|
||||
|
||||
|
||||
@method_decorator(never_cache, name='dispatch')
|
||||
class FlashWeComBindSucceedMsgView(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 WeCom successfully'),
|
||||
'messages': msg or _('Binding WeCom 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 FlashWeComBindFailedMsgView(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 WeCom failed'),
|
||||
'messages': msg or _('Binding WeCom failed'),
|
||||
'interval': 5,
|
||||
'redirect_url': request.GET.get('redirect_url'),
|
||||
'auto_redirect': True,
|
||||
}
|
||||
return self.render_to_response(context)
|
|
@ -0,0 +1,168 @@
|
|||
import time
|
||||
import hmac
|
||||
import base64
|
||||
|
||||
from common.message.backends.utils import request
|
||||
from common.message.backends.utils import digest
|
||||
from common.message.backends.mixin import BaseRequest
|
||||
|
||||
|
||||
def sign(secret, data):
|
||||
|
||||
digest = hmac.HMAC(
|
||||
key=secret.encode('utf8'),
|
||||
msg=data.encode('utf8'),
|
||||
digestmod=hmac._hashlib.sha256).digest()
|
||||
signature = base64.standard_b64encode(digest).decode('utf8')
|
||||
# signature = urllib.parse.quote(signature, safe='')
|
||||
# signature = signature.replace('+', '%20').replace('*', '%2A').replace('~', '%7E').replace('/', '%2F')
|
||||
return signature
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
INVALID_TOKEN = 88
|
||||
|
||||
|
||||
class URL:
|
||||
QR_CONNECT = 'https://oapi.dingtalk.com/connect/qrconnect'
|
||||
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'
|
||||
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'
|
||||
|
||||
|
||||
class DingTalkRequests(BaseRequest):
|
||||
invalid_token_errcode = ErrorCode.INVALID_TOKEN
|
||||
|
||||
def __init__(self, appid, appsecret, agentid, timeout=None):
|
||||
self._appid = appid
|
||||
self._appsecret = appsecret
|
||||
self._agentid = agentid
|
||||
|
||||
super().__init__(timeout=timeout)
|
||||
|
||||
def get_access_token_cache_key(self):
|
||||
return digest(self._appid, self._appsecret)
|
||||
|
||||
def request_access_token(self):
|
||||
# https://developers.dingtalk.com/document/app/obtain-orgapp-token?spm=ding_open_doc.document.0.0.3a256573JEWqIL#topic-1936350
|
||||
params = {'appkey': self._appid, 'appsecret': self._appsecret}
|
||||
data = self.raw_request('get', url=URL.GET_TOKEN, params=params)
|
||||
|
||||
access_token = data['access_token']
|
||||
expires_in = data['expires_in']
|
||||
return access_token, expires_in
|
||||
|
||||
@request
|
||||
def get(self, url, params=None,
|
||||
with_token=False, with_sign=False,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
pass
|
||||
|
||||
@request
|
||||
def post(self, url, json=None, params=None,
|
||||
with_token=False, with_sign=False,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
pass
|
||||
|
||||
def _add_sign(self, params: dict):
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
signature = sign(self._appsecret, timestamp)
|
||||
accessKey = self._appid
|
||||
|
||||
params['timestamp'] = timestamp
|
||||
params['signature'] = signature
|
||||
params['accessKey'] = accessKey
|
||||
|
||||
def request(self, method, url, params=None,
|
||||
with_token=False, with_sign=False,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
|
||||
if with_token:
|
||||
params['access_token'] = self.access_token
|
||||
|
||||
if with_sign:
|
||||
self._add_sign(params)
|
||||
|
||||
data = self.raw_request(method, url, params=params, **kwargs)
|
||||
if check_errcode_is_0:
|
||||
self.check_errcode_is_0(data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class DingTalk:
|
||||
def __init__(self, appid, appsecret, agentid, timeout=None):
|
||||
self._appid = appid
|
||||
self._appsecret = appsecret
|
||||
self._agentid = agentid
|
||||
|
||||
self._request = DingTalkRequests(
|
||||
appid=appid, appsecret=appsecret, agentid=agentid,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
def get_userinfo_bycode(self, code):
|
||||
# https://developers.dingtalk.com/document/app/obtain-the-user-information-based-on-the-sns-temporary-authorization?spm=ding_open_doc.document.0.0.3a256573y8Y7yg#topic-1995619
|
||||
body = {
|
||||
"tmp_auth_code": code
|
||||
}
|
||||
|
||||
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):
|
||||
user_info = self.get_userinfo_bycode(code)
|
||||
unionid = user_info['unionid']
|
||||
userid = self.get_userid_by_unionid(unionid)
|
||||
return userid
|
||||
|
||||
def get_userid_by_unionid(self, unionid):
|
||||
body = {
|
||||
'unionid': unionid
|
||||
}
|
||||
data = self._request.post(URL.GET_USERID_BY_UNIONID, json=body, with_token=True)
|
||||
userid = data['result']['userid']
|
||||
return userid
|
||||
|
||||
def send_by_template(self, template_id, user_ids, dept_ids, data):
|
||||
body = {
|
||||
'agent_id': self._agentid,
|
||||
'template_id': template_id,
|
||||
'userid_list': ','.join(user_ids),
|
||||
'dept_id_list': ','.join(dept_ids),
|
||||
'data': data
|
||||
}
|
||||
data = self._request.post(URL.SEND_MESSAGE_BY_TEMPLATE, json=body, with_token=True)
|
||||
|
||||
def send_text(self, user_ids, msg):
|
||||
body = {
|
||||
'agent_id': self._agentid,
|
||||
'userid_list': ','.join(user_ids),
|
||||
# 'dept_id_list': '',
|
||||
'to_all_user': False,
|
||||
'msg': {
|
||||
'msgtype': 'text',
|
||||
'text': {
|
||||
'content': msg
|
||||
}
|
||||
}
|
||||
}
|
||||
data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True)
|
||||
return data
|
||||
|
||||
def get_send_msg_progress(self, task_id):
|
||||
body = {
|
||||
'agent_id': self._agentid,
|
||||
'task_id': task_id
|
||||
}
|
||||
|
||||
data = self._request.post(URL.GET_SEND_MSG_PROGRESS, json=body, with_token=True)
|
||||
return data
|
|
@ -0,0 +1,28 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework.exceptions import APIException
|
||||
|
||||
|
||||
class HTTPNot200(APIException):
|
||||
default_code = 'http_not_200'
|
||||
default_detail = 'HTTP status is not 200'
|
||||
|
||||
|
||||
class ErrCodeNot0(APIException):
|
||||
default_code = 'errcode_not_0'
|
||||
default_detail = 'Error code is not 0'
|
||||
|
||||
|
||||
class ResponseDataKeyError(APIException):
|
||||
default_code = 'response_data_key_error'
|
||||
default_detail = 'Response data key error'
|
||||
|
||||
|
||||
class NetError(APIException):
|
||||
default_code = 'net_error'
|
||||
default_detail = _('Network error, please contact system administrator')
|
||||
|
||||
|
||||
class AccessTokenError(APIException):
|
||||
default_code = 'access_token_error'
|
||||
default_detail = 'Access token error, check config'
|
|
@ -0,0 +1,94 @@
|
|||
import requests
|
||||
from requests import exceptions as req_exce
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from django.core.cache import cache
|
||||
|
||||
from .utils import DictWrapper
|
||||
from common.utils.common import get_logger
|
||||
from common.utils import lazyproperty
|
||||
from common.message.backends.utils import set_default
|
||||
|
||||
from . import exceptions as exce
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RequestMixin:
|
||||
def check_errcode_is_0(self, data: DictWrapper):
|
||||
errcode = data['errcode']
|
||||
if errcode != 0:
|
||||
# 如果代码写的对,配置没问题,这里不该出错,系统性错误,直接抛异常
|
||||
errmsg = data['errmsg']
|
||||
logger.error(f'Response 200 but errcode is not 0: '
|
||||
f'errcode={errcode} '
|
||||
f'errmsg={errmsg} ')
|
||||
raise exce.ErrCodeNot0(detail=str(data.raw_data))
|
||||
|
||||
def check_http_is_200(self, response):
|
||||
if response.status_code != 200:
|
||||
# 正常情况下不会返回非 200 响应码
|
||||
logger.error(f'Response error: '
|
||||
f'status_code={response.status_code} '
|
||||
f'url={response.url}'
|
||||
f'\ncontent={response.content}')
|
||||
raise exce.HTTPNot200
|
||||
|
||||
|
||||
class BaseRequest(RequestMixin):
|
||||
invalid_token_errcode = -1
|
||||
|
||||
def __init__(self, timeout=None):
|
||||
self._request_kwargs = {
|
||||
'timeout': timeout
|
||||
}
|
||||
self.init_access_token()
|
||||
|
||||
def request_access_token(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_access_token_cache_key(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def is_token_invalid(self, data):
|
||||
errcode = data['errcode']
|
||||
if errcode == self.invalid_token_errcode:
|
||||
return True
|
||||
return False
|
||||
|
||||
@lazyproperty
|
||||
def access_token_cache_key(self):
|
||||
return self.get_access_token_cache_key()
|
||||
|
||||
def init_access_token(self):
|
||||
access_token = cache.get(self.access_token_cache_key)
|
||||
if access_token:
|
||||
self.access_token = access_token
|
||||
return
|
||||
self.refresh_access_token()
|
||||
|
||||
def refresh_access_token(self):
|
||||
access_token, expires_in = self.request_access_token()
|
||||
self.access_token = access_token
|
||||
cache.set(self.access_token_cache_key, access_token, expires_in)
|
||||
|
||||
def raw_request(self, method, url, **kwargs):
|
||||
set_default(kwargs, self._request_kwargs)
|
||||
raw_data = ''
|
||||
for i in range(3):
|
||||
# 循环为了防止 access_token 失效
|
||||
try:
|
||||
response = getattr(requests, method)(url, **kwargs)
|
||||
self.check_http_is_200(response)
|
||||
raw_data = response.json()
|
||||
data = DictWrapper(raw_data)
|
||||
|
||||
if self.is_token_invalid(data):
|
||||
self.refresh_access_token()
|
||||
continue
|
||||
|
||||
return data
|
||||
except req_exce.ReadTimeout as e:
|
||||
logger.exception(e)
|
||||
raise exce.NetError
|
||||
logger.error(f'Get access_token error, check config: url={url} data={raw_data}')
|
||||
raise PermissionDenied(raw_data)
|
|
@ -0,0 +1,78 @@
|
|||
import hashlib
|
||||
import inspect
|
||||
from inspect import Parameter
|
||||
|
||||
from common.utils.common import get_logger
|
||||
from common.message.backends import exceptions as exce
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def digest(corpid, corpsecret):
|
||||
md5 = hashlib.md5()
|
||||
md5.update(corpid.encode())
|
||||
md5.update(corpsecret.encode())
|
||||
digest = md5.hexdigest()
|
||||
return digest
|
||||
|
||||
|
||||
def update_values(default: dict, others: dict):
|
||||
for key in default.keys():
|
||||
if key in others:
|
||||
default[key] = others[key]
|
||||
|
||||
|
||||
def set_default(data: dict, default: dict):
|
||||
for key in default.keys():
|
||||
if key not in data:
|
||||
data[key] = default[key]
|
||||
|
||||
|
||||
class DictWrapper:
|
||||
def __init__(self, data:dict):
|
||||
self.raw_data = data
|
||||
|
||||
def __getitem__(self, item):
|
||||
# 网络请求返回的数据,不能完全信任,所以字典操作包在异常里
|
||||
try:
|
||||
return self.raw_data[item]
|
||||
except KeyError as e:
|
||||
msg = f'Response 200 but get field from json error: error={e} data={self.raw_data}'
|
||||
logger.error(msg)
|
||||
raise exce.ResponseDataKeyError(detail=msg)
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self.raw_data, item)
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.raw_data
|
||||
|
||||
def __str__(self):
|
||||
return str(self.raw_data)
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.raw_data)
|
||||
|
||||
|
||||
def request(func):
|
||||
def inner(*args, **kwargs):
|
||||
signature = inspect.signature(func)
|
||||
bound_args = signature.bind(*args, **kwargs)
|
||||
bound_args.apply_defaults()
|
||||
|
||||
arguments = bound_args.arguments
|
||||
self = arguments['self']
|
||||
request_method = func.__name__
|
||||
|
||||
parameters = {}
|
||||
for k, v in signature.parameters.items():
|
||||
if k == 'self':
|
||||
continue
|
||||
if v.kind is Parameter.VAR_KEYWORD:
|
||||
parameters.update(arguments[k])
|
||||
continue
|
||||
parameters[k] = arguments[k]
|
||||
|
||||
response = self.request(request_method, **parameters)
|
||||
return response
|
||||
return inner
|
|
@ -0,0 +1,194 @@
|
|||
from typing import Iterable, AnyStr
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework.exceptions import APIException
|
||||
from requests.exceptions import ReadTimeout
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
|
||||
from common.utils.common import get_logger
|
||||
from common.message.backends.utils import digest, DictWrapper, update_values, set_default
|
||||
from common.message.backends.utils import request
|
||||
from common.message.backends.mixin import RequestMixin, BaseRequest
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WeComError(APIException):
|
||||
default_code = 'wecom_error'
|
||||
default_detail = _('WeCom error, please contact system administrator')
|
||||
|
||||
|
||||
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'
|
||||
|
||||
# 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'
|
||||
GET_USER_DETAIL = 'https://qyapi.weixin.qq.com/cgi-bin/user/get'
|
||||
|
||||
|
||||
class ErrorCode:
|
||||
# https://open.work.weixin.qq.com/api/doc/90000/90139/90313#%E9%94%99%E8%AF%AF%E7%A0%81%EF%BC%9A81013
|
||||
RECIPIENTS_INVALID = 81013 # UserID、部门ID、标签ID全部非法或无权限。
|
||||
|
||||
# https://open.work.weixin.qq.com/api/doc/90000/90135/91437
|
||||
INVALID_CODE = 40029
|
||||
|
||||
INVALID_TOKEN = 40014 # 无效的 access_token
|
||||
|
||||
|
||||
class WeComRequests(BaseRequest):
|
||||
"""
|
||||
处理系统级错误,抛出 API 异常,直接生成 HTTP 响应,业务代码无需关心这些错误
|
||||
- 确保 status_code == 200
|
||||
- 确保 access_token 无效时重试
|
||||
"""
|
||||
invalid_token_errcode = ErrorCode.INVALID_TOKEN
|
||||
|
||||
def __init__(self, corpid, corpsecret, agentid, timeout=None):
|
||||
self._corpid = corpid
|
||||
self._corpsecret = corpsecret
|
||||
self._agentid = agentid
|
||||
|
||||
super().__init__(timeout=timeout)
|
||||
|
||||
def get_access_token_cache_key(self):
|
||||
return digest(self._corpid, self._corpsecret)
|
||||
|
||||
def request_access_token(self):
|
||||
params = {'corpid': self._corpid, 'corpsecret': self._corpsecret}
|
||||
data = self.raw_request('get', url=URL.GET_TOKEN, params=params)
|
||||
|
||||
access_token = data['access_token']
|
||||
expires_in = data['expires_in']
|
||||
return access_token, expires_in
|
||||
|
||||
@request
|
||||
def get(self, url, params=None, with_token=True,
|
||||
check_errcode_is_0=True, **kwargs):
|
||||
# self.request ...
|
||||
pass
|
||||
|
||||
@request
|
||||
def post(self, url, params=None, json=None,
|
||||
with_token=True, check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
# self.request ...
|
||||
pass
|
||||
|
||||
def request(self, method, url,
|
||||
params=None,
|
||||
with_token=True,
|
||||
check_errcode_is_0=True,
|
||||
**kwargs):
|
||||
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
|
||||
if with_token:
|
||||
params['access_token'] = self.access_token
|
||||
|
||||
data = self.raw_request(method, url, params=params, **kwargs)
|
||||
if check_errcode_is_0:
|
||||
self.check_errcode_is_0(data)
|
||||
return data
|
||||
|
||||
|
||||
class WeCom(RequestMixin):
|
||||
"""
|
||||
非业务数据导致的错误直接抛异常,说明是系统配置错误,业务代码不用理会
|
||||
"""
|
||||
|
||||
def __init__(self, corpid, corpsecret, agentid, timeout=None):
|
||||
self._corpid = corpid
|
||||
self._corpsecret = corpsecret
|
||||
self._agentid = agentid
|
||||
|
||||
self._requests = WeComRequests(
|
||||
corpid=corpid,
|
||||
corpsecret=corpsecret,
|
||||
agentid=agentid,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
def send_text(self, users: Iterable, msg: AnyStr, **kwargs):
|
||||
"""
|
||||
https://open.work.weixin.qq.com/api/doc/90000/90135/90236
|
||||
|
||||
对于业务代码,只需要关心由 用户id 或 消息不对 导致的错误,其他错误不予理会
|
||||
"""
|
||||
users = tuple(users)
|
||||
|
||||
extra_params = {
|
||||
"safe": 0,
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0,
|
||||
"duplicate_check_interval": 1800
|
||||
}
|
||||
update_values(extra_params, kwargs)
|
||||
|
||||
body = {
|
||||
"touser": '|'.join(users),
|
||||
"msgtype": "text",
|
||||
"agentid": self._agentid,
|
||||
"text": {
|
||||
"content": msg
|
||||
},
|
||||
**extra_params
|
||||
}
|
||||
data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False)
|
||||
|
||||
errcode = data['errcode']
|
||||
if errcode == ErrorCode.RECIPIENTS_INVALID:
|
||||
# 全部接收人无权限或不存在
|
||||
return users
|
||||
self.check_errcode_is_0(data)
|
||||
|
||||
invaliduser = data['invaliduser']
|
||||
if not invaliduser:
|
||||
return ()
|
||||
|
||||
if isinstance(invaliduser, str):
|
||||
logger.error(f'WeCom send text 200, but invaliduser is not str: invaliduser={invaliduser}')
|
||||
raise WeComError
|
||||
|
||||
invalid_users = invaliduser.split('|')
|
||||
return invalid_users
|
||||
|
||||
def get_user_id_by_code(self, code):
|
||||
# # https://open.work.weixin.qq.com/api/doc/90000/90135/91437
|
||||
|
||||
params = {
|
||||
'code': code,
|
||||
}
|
||||
data = self._requests.get(URL.GET_USER_ID_BY_CODE, params=params, check_errcode_is_0=False)
|
||||
|
||||
errcode = data['errcode']
|
||||
if errcode == ErrorCode.INVALID_CODE:
|
||||
logger.warn(f'WeCom get_user_id_by_code invalid code: code={code}')
|
||||
return None, None
|
||||
|
||||
self.check_errcode_is_0(data)
|
||||
|
||||
USER_ID = 'UserId'
|
||||
OPEN_ID = 'OpenId'
|
||||
|
||||
if USER_ID in data:
|
||||
return data[USER_ID], USER_ID
|
||||
elif OPEN_ID in data:
|
||||
return data[OPEN_ID], OPEN_ID
|
||||
else:
|
||||
logger.error(f'WeCom response 200 but get field from json error: fields=UserId|OpenId')
|
||||
raise WeComError
|
||||
|
||||
def get_user_detail(self, id):
|
||||
# https://open.work.weixin.qq.com/api/doc/90000/90135/90196
|
||||
|
||||
params = {
|
||||
'userid': id,
|
||||
}
|
||||
|
||||
data = self._requests.get(URL.GET_USER_DETAIL, params)
|
||||
return data
|
|
@ -6,10 +6,12 @@ from threading import Thread
|
|||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.core.cache import cache
|
||||
from django.http import JsonResponse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.decorators import action
|
||||
|
@ -25,6 +27,9 @@ __all__ = [
|
|||
]
|
||||
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class JSONResponseMixin(object):
|
||||
"""JSON mixin"""
|
||||
@staticmethod
|
||||
|
@ -332,3 +337,21 @@ class AllowBulkDestoryMixin:
|
|||
"""
|
||||
query = str(filtered.query)
|
||||
return '`id` IN (' in query or '`id` =' in query
|
||||
|
||||
|
||||
class RoleAdminMixin:
|
||||
kwargs: dict
|
||||
user_id_url_kwarg = 'pk'
|
||||
|
||||
@lazyproperty
|
||||
def user(self):
|
||||
user_id = self.kwargs.get(self.user_id_url_kwarg)
|
||||
return UserModel.objects.get(id=user_id)
|
||||
|
||||
|
||||
class RoleUserMixin:
|
||||
request: Request
|
||||
|
||||
@lazyproperty
|
||||
def user(self):
|
||||
return self.request.user
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
|
||||
from orgs.utils import current_org
|
||||
|
||||
|
||||
class RequestLogMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest):
|
||||
print(f'Request {request.method} --> ', request.get_raw_uri())
|
||||
response: HttpResponse = self.get_response(request)
|
||||
print(f'Response {current_org.name} {request.method} {response.status_code} --> ', request.get_raw_uri())
|
||||
return response
|
|
@ -4,7 +4,6 @@ import struct
|
|||
import random
|
||||
import socket
|
||||
import string
|
||||
import secrets
|
||||
|
||||
|
||||
string_punctuation = '!#$%&()*+,-.:;<=>?@[]^_{}~'
|
||||
|
|
|
@ -216,6 +216,16 @@ class Config(dict):
|
|||
'AUTH_SSO': False,
|
||||
'AUTH_SSO_AUTHKEY_TTL': 60 * 15,
|
||||
|
||||
'AUTH_WECOM': False,
|
||||
'WECOM_CORPID': '',
|
||||
'WECOM_AGENTID': '',
|
||||
'WECOM_CORPSECRET': '',
|
||||
|
||||
'AUTH_DINGTALK': False,
|
||||
'DINGTALK_AGENTID': '',
|
||||
'DINGTALK_APPKEY': '',
|
||||
'DINGTALK_APPSECRET': '',
|
||||
|
||||
'OTP_VALID_WINDOW': 2,
|
||||
'OTP_ISSUER_NAME': 'JumpServer',
|
||||
'EMAIL_SUFFIX': 'jumpserver.org',
|
||||
|
|
|
@ -14,6 +14,8 @@ def jumpserver_processor(request):
|
|||
'LOGIN_IMAGE_URL': static('img/login_image.png'),
|
||||
'FAVICON_URL': static('img/facio.ico'),
|
||||
'LOGIN_CAS_LOGO_URL': static('img/login_cas_logo.png'),
|
||||
'LOGIN_WECOM_LOGO_URL': static('img/login_wecom_log.png'),
|
||||
'LOGIN_DINGTALK_LOGO_URL': static('img/login_dingtalk_log.png'),
|
||||
'JMS_TITLE': _('JumpServer Open Source Bastion Host'),
|
||||
'VERSION': settings.VERSION,
|
||||
'COPYRIGHT': 'FIT2CLOUD 飞致云' + ' © 2014-2021',
|
||||
|
|
|
@ -101,6 +101,19 @@ CAS_CHECK_NEXT = lambda _next_page: True
|
|||
AUTH_SSO = CONFIG.AUTH_SSO
|
||||
AUTH_SSO_AUTHKEY_TTL = CONFIG.AUTH_SSO_AUTHKEY_TTL
|
||||
|
||||
# WECOM Auth
|
||||
AUTH_WECOM = CONFIG.AUTH_WECOM
|
||||
WECOM_CORPID = CONFIG.WECOM_CORPID
|
||||
WECOM_AGENTID = CONFIG.WECOM_AGENTID
|
||||
WECOM_CORPSECRET = CONFIG.WECOM_CORPSECRET
|
||||
|
||||
# DingDing auth
|
||||
AUTH_DINGTALK = CONFIG.AUTH_DINGTALK
|
||||
DINGTALK_AGENTID = CONFIG.DINGTALK_AGENTID
|
||||
DINGTALK_APPKEY = CONFIG.DINGTALK_APPKEY
|
||||
DINGTALK_APPSECRET = CONFIG.DINGTALK_APPSECRET
|
||||
|
||||
|
||||
# Other setting
|
||||
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
||||
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
|
||||
|
@ -115,6 +128,8 @@ AUTH_BACKEND_OIDC_CODE = 'jms_oidc_rp.backends.OIDCAuthCodeBackend'
|
|||
AUTH_BACKEND_RADIUS = 'authentication.backends.radius.RadiusBackend'
|
||||
AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend'
|
||||
AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication'
|
||||
AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication'
|
||||
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
||||
|
||||
|
||||
AUTHENTICATION_BACKENDS = [AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY]
|
||||
|
@ -128,6 +143,10 @@ if AUTH_RADIUS:
|
|||
AUTHENTICATION_BACKENDS.insert(0, AUTH_BACKEND_RADIUS)
|
||||
if AUTH_SSO:
|
||||
AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_SSO)
|
||||
if AUTH_WECOM:
|
||||
AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_WECOM)
|
||||
if AUTH_DINGTALK:
|
||||
AUTHENTICATION_BACKENDS.append(AUTH_BACKEND_DINGTALK)
|
||||
|
||||
|
||||
ONLY_ALLOW_EXIST_USER_AUTH = CONFIG.ONLY_ALLOW_EXIST_USER_AUTH
|
||||
|
|
|
@ -23,7 +23,7 @@ api_v1 = [
|
|||
path('applications/', include('applications.urls.api_urls', namespace='api-applications')),
|
||||
path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')),
|
||||
path('acls/', include('acls.urls.api_urls', namespace='api-acls')),
|
||||
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view())
|
||||
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()),
|
||||
]
|
||||
|
||||
app_view_patterns = [
|
||||
|
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
@ -1,10 +1,12 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OpsConfig(AppConfig):
|
||||
name = 'ops'
|
||||
verbose_name = _('Operations')
|
||||
|
||||
def ready(self):
|
||||
from orgs.models import Organization
|
||||
|
|
|
@ -8,7 +8,6 @@ from django.dispatch import receiver
|
|||
from django.utils.functional import LazyObject
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.db.models.signals import post_save, post_delete, pre_delete
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from orgs.utils import tmp_to_org
|
||||
from orgs.models import Organization, OrganizationMember
|
||||
|
@ -19,7 +18,6 @@ from common.const.signals import PRE_REMOVE, POST_REMOVE
|
|||
from common.signals import django_ready
|
||||
from common.utils import get_logger
|
||||
from common.utils.connection import RedisPubSub
|
||||
from common.exceptions import JMSException
|
||||
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
from rest_framework.request import Request
|
||||
|
||||
from common.permissions import IsOrgAdminOrAppUser, IsValidUser
|
||||
from common.utils import lazyproperty
|
||||
from common.http import is_true
|
||||
from common.mixins.api import RoleAdminMixin as _RoleAdminMixin
|
||||
from common.mixins.api import RoleUserMixin as _RoleUserMixin
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from users.models import User
|
||||
from perms.utils.asset.user_permission import UserGrantedTreeRefreshController
|
||||
|
@ -20,24 +21,13 @@ class PermBaseMixin:
|
|||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
class RoleAdminMixin(PermBaseMixin):
|
||||
class RoleAdminMixin(PermBaseMixin, _RoleAdminMixin):
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
kwargs: dict
|
||||
|
||||
@lazyproperty
|
||||
def user(self):
|
||||
user_id = self.kwargs.get('pk')
|
||||
return User.objects.get(id=user_id)
|
||||
|
||||
|
||||
class RoleUserMixin(PermBaseMixin):
|
||||
class RoleUserMixin(PermBaseMixin, _RoleUserMixin):
|
||||
permission_classes = (IsValidUser,)
|
||||
request: Request
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
with tmp_to_root_org():
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
@lazyproperty
|
||||
def user(self):
|
||||
return self.request.user
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
from .common import *
|
||||
from .ldap import *
|
||||
from .wecom import *
|
||||
from .dingtalk import *
|
||||
|
|
|
@ -125,7 +125,9 @@ class PublicSettingApi(generics.RetrieveAPIView):
|
|||
'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE,
|
||||
'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER,
|
||||
'SECURITY_PASSWORD_SPECIAL_CHAR': settings.SECURITY_PASSWORD_SPECIAL_CHAR,
|
||||
}
|
||||
},
|
||||
"AUTH_WECOM": settings.AUTH_WECOM,
|
||||
"AUTH_DINGTALK": settings.AUTH_DINGTALK,
|
||||
}
|
||||
}
|
||||
return instance
|
||||
|
@ -141,6 +143,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
|
|||
'ldap': serializers.LDAPSettingSerializer,
|
||||
'email': serializers.EmailSettingSerializer,
|
||||
'email_content': serializers.EmailContentSettingSerializer,
|
||||
'wecom': serializers.WeComSettingSerializer,
|
||||
'dingtalk': serializers.DingTalkSettingSerializer,
|
||||
}
|
||||
|
||||
def get_serializer_class(self):
|
||||
|
@ -163,6 +167,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
|
|||
category = self.request.query_params.get('category', '')
|
||||
for name, value in serializer.validated_data.items():
|
||||
encrypted = name in encrypted_items
|
||||
if encrypted and value in ['', None]:
|
||||
continue
|
||||
data.append({
|
||||
'name': name, 'value': value,
|
||||
'encrypted': encrypted, 'category': category
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import requests
|
||||
|
||||
from rest_framework.views import Response
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.permissions import IsSuperUser
|
||||
from common.message.backends.dingtalk import URL
|
||||
|
||||
from .. import serializers
|
||||
|
||||
|
||||
class DingTalkTestingAPI(GenericAPIView):
|
||||
permission_classes = (IsSuperUser,)
|
||||
serializer_class = serializers.DingTalkSettingSerializer
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
dingtalk_appkey = serializer.validated_data['DINGTALK_APPKEY']
|
||||
dingtalk_agentid = serializer.validated_data['DINGTALK_AGENTID']
|
||||
dingtalk_appsecret = serializer.validated_data['DINGTALK_APPSECRET']
|
||||
|
||||
try:
|
||||
params = {'appkey': dingtalk_appkey, 'appsecret': dingtalk_appsecret}
|
||||
resp = requests.get(url=URL.GET_TOKEN, params=params)
|
||||
if resp.status_code != 200:
|
||||
return Response(status=400, data={'error': resp.json()})
|
||||
|
||||
data = resp.json()
|
||||
errcode = data['errcode']
|
||||
if errcode != 0:
|
||||
return Response(status=400, data={'error': data['errmsg']})
|
||||
|
||||
return Response(status=200, data={'msg': _('OK')})
|
||||
except Exception as e:
|
||||
return Response(status=400, data={'error': str(e)})
|
|
@ -0,0 +1,38 @@
|
|||
import requests
|
||||
|
||||
from rest_framework.views import Response
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.permissions import IsSuperUser
|
||||
from common.message.backends.wecom import URL
|
||||
|
||||
from .. import serializers
|
||||
|
||||
|
||||
class WeComTestingAPI(GenericAPIView):
|
||||
permission_classes = (IsSuperUser,)
|
||||
serializer_class = serializers.WeComSettingSerializer
|
||||
|
||||
def post(self, request):
|
||||
serializer = self.serializer_class(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
wecom_corpid = serializer.validated_data['WECOM_CORPID']
|
||||
wecom_agentid = serializer.validated_data['WECOM_AGENTID']
|
||||
wecom_corpsecret = serializer.validated_data['WECOM_CORPSECRET']
|
||||
|
||||
try:
|
||||
params = {'corpid': wecom_corpid, 'corpsecret': wecom_corpsecret}
|
||||
resp = requests.get(url=URL.GET_TOKEN, params=params)
|
||||
if resp.status_code != 200:
|
||||
return Response(status=400, data={'error': resp.json()})
|
||||
|
||||
data = resp.json()
|
||||
errcode = data['errcode']
|
||||
if errcode != 0:
|
||||
return Response(status=400, data={'error': data['errmsg']})
|
||||
|
||||
return Response(status=200, data={'msg': _('OK')})
|
||||
except Exception as e:
|
||||
return Response(status=400, data={'error': str(e)})
|
|
@ -6,7 +6,7 @@ from rest_framework import serializers
|
|||
__all__ = [
|
||||
'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer',
|
||||
'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer',
|
||||
'SettingsSerializer'
|
||||
'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer',
|
||||
]
|
||||
|
||||
|
||||
|
@ -189,13 +189,29 @@ class SecuritySettingSerializer(serializers.Serializer):
|
|||
)
|
||||
|
||||
|
||||
class WeComSettingSerializer(serializers.Serializer):
|
||||
WECOM_CORPID = serializers.CharField(max_length=256, required=True, label=_('Corporation ID'))
|
||||
WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label=_("Agent ID"))
|
||||
WECOM_CORPSECRET = serializers.CharField(max_length=256, required=False, label=_("Corporation Secret"), write_only=True)
|
||||
AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth'))
|
||||
|
||||
|
||||
class DingTalkSettingSerializer(serializers.Serializer):
|
||||
DINGTALK_AGENTID = serializers.CharField(max_length=256, required=True, label=_("AgentId"))
|
||||
DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label=_("AppKey"))
|
||||
DINGTALK_APPSECRET = serializers.CharField(max_length=256, required=False, label=_("AppSecret"), write_only=True)
|
||||
AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth'))
|
||||
|
||||
|
||||
class SettingsSerializer(
|
||||
BasicSettingSerializer,
|
||||
EmailSettingSerializer,
|
||||
EmailContentSettingSerializer,
|
||||
LDAPSettingSerializer,
|
||||
TerminalSettingSerializer,
|
||||
SecuritySettingSerializer
|
||||
SecuritySettingSerializer,
|
||||
WeComSettingSerializer,
|
||||
DingTalkSettingSerializer,
|
||||
):
|
||||
|
||||
# encrypt_fields 现在使用 write_only 来判断了
|
||||
|
|
|
@ -13,6 +13,8 @@ urlpatterns = [
|
|||
path('ldap/users/', api.LDAPUserListApi.as_view(), name='ldap-user-list'),
|
||||
path('ldap/users/import/', api.LDAPUserImportAPI.as_view(), name='ldap-user-import'),
|
||||
path('ldap/cache/refresh/', api.LDAPCacheRefreshAPI.as_view(), name='ldap-cache-refresh'),
|
||||
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'),
|
||||
path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'),
|
||||
|
||||
path('setting/', api.SettingsApi.as_view(), name='settings-setting'),
|
||||
path('public/', api.PublicSettingApi.as_view(), name='public-setting'),
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 134 KiB |
|
@ -1,10 +1,12 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TerminalConfig(AppConfig):
|
||||
name = 'terminal'
|
||||
verbose_name = _('Terminal')
|
||||
|
||||
def ready(self):
|
||||
from . import signals_handler
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.1 on 2021-05-06 06:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0033_user_need_update_password'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='dingtalk_id',
|
||||
field=models.CharField(default=None, max_length=128, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='wecom_id',
|
||||
field=models.CharField(default=None, max_length=128, null=True, unique=True),
|
||||
),
|
||||
]
|
|
@ -541,7 +541,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
|||
cas = 'cas', 'CAS'
|
||||
|
||||
SOURCE_BACKEND_MAPPING = {
|
||||
Source.local: [settings.AUTH_BACKEND_MODEL, settings.AUTH_BACKEND_PUBKEY],
|
||||
Source.local: [
|
||||
settings.AUTH_BACKEND_MODEL, settings.AUTH_BACKEND_PUBKEY,
|
||||
settings.AUTH_BACKEND_WECOM, settings.AUTH_BACKEND_DINGTALK,
|
||||
],
|
||||
Source.ldap: [settings.AUTH_BACKEND_LDAP],
|
||||
Source.openid: [settings.AUTH_BACKEND_OIDC_PASSWORD, settings.AUTH_BACKEND_OIDC_CODE],
|
||||
Source.radius: [settings.AUTH_BACKEND_RADIUS],
|
||||
|
@ -605,10 +608,20 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser):
|
|||
verbose_name=_('Date password last updated')
|
||||
)
|
||||
need_update_password = models.BooleanField(default=False)
|
||||
wecom_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
||||
dingtalk_id = models.CharField(null=True, default=None, unique=True, max_length=128)
|
||||
|
||||
def __str__(self):
|
||||
return '{0.name}({0.username})'.format(self)
|
||||
|
||||
@property
|
||||
def is_wecom_bound(self):
|
||||
return bool(self.wecom_id)
|
||||
|
||||
@property
|
||||
def is_dingtalk_bound(self):
|
||||
return bool(self.dingtalk_id)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('users:user-detail', args=(self.id,))
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
from rest_framework import permissions
|
||||
|
||||
from .utils import is_auth_password_time_valid
|
||||
|
||||
|
||||
class IsAuthPasswdTimeValid(permissions.IsAuthenticated):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return super().has_permission(request, view) \
|
||||
and is_auth_password_time_valid(request.session)
|
|
@ -55,6 +55,7 @@ class UserSerializer(CommonBulkSerializerMixin, serializers.ModelSerializer):
|
|||
'mfa_enabled', 'is_valid', 'is_expired', 'is_active', # 布尔字段
|
||||
'date_expired', 'date_joined', 'last_login', # 日期字段
|
||||
'created_by', 'comment', # 通用字段
|
||||
'is_wecom_bound', 'is_dingtalk_bound',
|
||||
]
|
||||
# 包含不太常用的字段,可以没有
|
||||
fields_verbose = fields_small + [
|
||||
|
|
Loading…
Reference in New Issue