mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-09-13 13:59:17 +00:00
feat: 添加企业微信,钉钉扫码登录
This commit is contained in:
@@ -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 *
|
||||
|
35
apps/authentication/api/dingtalk.py
Normal file
35
apps/authentication/api/dingtalk.py
Normal file
@@ -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,)
|
26
apps/authentication/api/password.py
Normal file
26
apps/authentication/api/password.py
Normal file
@@ -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()
|
35
apps/authentication/api/wecom.py
Normal file
35
apps/authentication/api/wecom.py
Normal file
@@ -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 *
|
||||
|
243
apps/authentication/views/dingtalk.py
Normal file
243
apps/authentication/views/dingtalk.py
Normal file
@@ -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
|
||||
}
|
||||
|
241
apps/authentication/views/wecom.py
Normal file
241
apps/authentication/views/wecom.py
Normal file
@@ -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)
|
Reference in New Issue
Block a user