diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py
index 527e473d8..93b33c4f9 100644
--- a/apps/authentication/api/login_confirm.py
+++ b/apps/authentication/api/login_confirm.py
@@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
ticket = self.get_ticket()
if ticket:
request.session.pop('auth_ticket_id', '')
- ticket.close(processor=request.user)
+ ticket.close(processor=self.get_user_from_session())
return Response('', status=200)
diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py
index b81eeee29..f067d5c5c 100644
--- a/apps/authentication/api/mfa.py
+++ b/apps/authentication/api/mfa.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
#
+import builtins
import time
from django.utils.translation import ugettext as _
from django.conf import settings
@@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView
from rest_framework.serializers import ValidationError
from rest_framework.response import Response
-from common.permissions import IsValidUser, NeedMFAVerify
+from authentication.sms_verify_code import VerifyCodeUtil
+from common.exceptions import JMSException
+from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
+from users.models.user import MFAType
from ..serializers import OtpVerifySerializer
from .. import serializers
from .. import errors
from ..mixins import AuthMixin
-__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
+__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
+
+
+class MFASelectTypeApi(AuthMixin, CreateAPIView):
+ permission_classes = (AllowAny,)
+ serializer_class = serializers.MFASelectTypeSerializer
+
+ def perform_create(self, serializer):
+ mfa_type = serializer.validated_data['type']
+ if mfa_type == MFAType.SMS_CODE:
+ user = self.get_user_from_session()
+ user.send_sms_code()
class MFAChallengeApi(AuthMixin, CreateAPIView):
@@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
try:
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
- valid = user.check_mfa(code)
+ mfa_type = serializer.validated_data.get('type', MFAType.OTP)
+
+ valid = user.check_mfa(code, mfa_type=mfa_type)
if not valid:
self.request.session['auth_mfa'] = ''
raise errors.MFAFailedError(
@@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView):
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [NeedMFAVerify]
return super().get_permissions()
+
+
+class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
+ permission_classes = (AllowAny,)
+
+ def create(self, request, *args, **kwargs):
+ user = self.get_user_from_session()
+ timeout = user.send_sms_code()
+ return Response({'code': 'ok','timeout': timeout})
diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py
index ad8148182..c8005ba95 100644
--- a/apps/authentication/errors.py
+++ b/apps/authentication/errors.py
@@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
+from authentication import sms_verify_code
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils
+from users.models import MFAType
reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed'
@@ -58,8 +60,18 @@ block_mfa_msg = _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
)
-mfa_failed_msg = _(
- "MFA code invalid, or ntp sync server time, "
+otp_failed_msg = _(
+ "One-time password invalid, or ntp sync server time, "
+ "You can also try {times_try} times "
+ "(The account will be temporarily locked for {block_time} minutes)"
+)
+sms_failed_msg = _(
+ "SMS verify code invalid,"
+ "You can also try {times_try} times "
+ "(The account will be temporarily locked for {block_time} minutes)"
+)
+mfa_type_failed_msg = _(
+ "The MFA type({mfa_type}) is not supported"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
@@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
error = reason_mfa_failed
msg: str
- def __init__(self, username, request, ip):
+ def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
util = MFABlockUtils(username, ip)
util.incr_failed_count()
@@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder:
- self.msg = mfa_failed_msg.format(
- times_try=times_remainder, block_time=block_time
- )
+ if mfa_type == MFAType.OTP:
+ self.msg = otp_failed_msg.format(
+ times_try=times_remainder, block_time=block_time
+ )
+ elif mfa_type == MFAType.SMS_CODE:
+ self.msg = sms_failed_msg.format(
+ times_try=times_remainder, block_time=block_time
+ )
+ else:
+ self.msg = mfa_type_failed_msg.format(
+ mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
+ )
else:
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
super().__init__(username=username, request=request)
@@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError):
msg = mfa_required_msg
error = 'mfa_required'
+ def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
+ super().__init__(error=error, msg=msg)
+ self.choices = mfa_types
+
def as_data(self):
return {
'error': self.error,
'msg': self.msg,
'data': {
- 'choices': ['code'],
+ 'choices': self.choices,
'url': reverse('api-auth:mfa-challenge')
}
}
diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py
index a4b07700c..839d71be6 100644
--- a/apps/authentication/forms.py
+++ b/apps/authentication/forms.py
@@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
class UserCheckOtpCodeForm(forms.Form):
- otp_code = forms.CharField(label=_('MFA code'), max_length=6)
+ code = forms.CharField(label=_('Code'), max_length=6)
+ mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
class CustomCaptchaTextInput(CaptchaTextInput):
diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py
index 715be0aae..3a2f9c09b 100644
--- a/apps/authentication/mixins.py
+++ b/apps/authentication/mixins.py
@@ -17,7 +17,7 @@ from django.shortcuts import reverse, redirect
from django.views.generic.edit import FormView
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
-from users.models import User
+from users.models import User, MFAType
from users.utils import LoginBlockUtil, MFABlockUtils
from . import errors
from .utils import rsa_decrypt, gen_key_pair
@@ -351,13 +351,13 @@ class AuthMixin(PasswordEncryptionViewMixin):
unset, url = user.mfa_enabled_but_not_set()
if unset:
raise errors.MFAUnsetError(user, self.request, url)
- raise errors.MFARequiredError()
+ raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
- def mark_mfa_ok(self):
+ def mark_mfa_ok(self, mfa_type=MFAType.OTP):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
- self.request.session['auth_mfa_type'] = 'otp'
self.request.session['auth_mfa_required'] = ''
+ self.request.session['auth_mfa_type'] = mfa_type
def check_mfa_is_block(self, username, ip, raise_exception=True):
if MFABlockUtils(username, ip).is_block():
@@ -368,11 +368,11 @@ class AuthMixin(PasswordEncryptionViewMixin):
else:
return exception
- def check_user_mfa(self, code):
+ def check_user_mfa(self, code, mfa_type=MFAType.OTP):
user = self.get_user_from_session()
ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip)
- ok = user.check_mfa(code)
+ ok = user.check_mfa(code, mfa_type=mfa_type)
if ok:
self.mark_mfa_ok()
return
@@ -380,7 +380,7 @@ class AuthMixin(PasswordEncryptionViewMixin):
raise errors.MFAFailedError(
username=user.username,
request=self.request,
- ip=ip
+ ip=ip, mfa_type=mfa_type,
)
def get_ticket(self):
diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py
index a9bd5e189..b571dea01 100644
--- a/apps/authentication/serializers.py
+++ b/apps/authentication/serializers.py
@@ -17,7 +17,7 @@ __all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
- 'PasswordVerifySerializer',
+ 'PasswordVerifySerializer', 'MFASelectTypeSerializer',
]
@@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer):
return instance
+class MFASelectTypeSerializer(serializers.Serializer):
+ type = serializers.CharField()
+
+
class MFAChallengeSerializer(serializers.Serializer):
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
code = serializers.CharField(write_only=True)
diff --git a/apps/authentication/sms_verify_code.py b/apps/authentication/sms_verify_code.py
new file mode 100644
index 000000000..33d17b207
--- /dev/null
+++ b/apps/authentication/sms_verify_code.py
@@ -0,0 +1,97 @@
+import random
+
+from django.conf import settings
+from django.core.cache import cache
+from django.utils.translation import gettext_lazy as _
+
+from common.message.backends.sms.alibaba import AlibabaSMS
+from common.message.backends.sms import SMS
+from common.utils import get_logger
+from common.exceptions import JMSException
+
+logger = get_logger(__file__)
+
+
+class CodeExpired(JMSException):
+ default_code = 'verify_code_expired'
+ default_detail = _('The verification code has expired. Please resend it')
+
+
+class CodeError(JMSException):
+ default_code = 'verify_code_error'
+ default_detail = _('The verification code is incorrect')
+
+
+class CodeSendTooFrequently(JMSException):
+ default_code = 'code_send_too_frequently'
+ default_detail = _('Please wait {} seconds before sending')
+
+ def __init__(self, ttl):
+ super().__init__(detail=self.default_detail.format(ttl))
+
+
+class VerifyCodeUtil:
+ KEY_TMPL = 'auth-verify_code-{}'
+ TIMEOUT = 60
+
+ def __init__(self, account, key_suffix=None, timeout=None):
+ self.account = account
+ self.key_suffix = key_suffix
+ self.code = ''
+
+ if key_suffix is not None:
+ self.key = self.KEY_TMPL.format(key_suffix)
+ else:
+ self.key = self.KEY_TMPL.format(account)
+ self.timeout = self.TIMEOUT if timeout is None else timeout
+
+ def touch(self):
+ """
+ 生成,保存,发送
+ """
+ ttl = self.ttl()
+ if ttl > 0:
+ raise CodeSendTooFrequently(ttl)
+
+ self.generate()
+ self.save()
+ self.send()
+
+ def generate(self):
+ code = ''.join(random.sample('0123456789', 4))
+ self.code = code
+ return code
+
+ def clear(self):
+ cache.delete(self.key)
+
+ def save(self):
+ cache.set(self.key, self.code, self.timeout)
+
+ def send(self):
+ """
+ 发送信息的方法,如果有错误直接抛出 api 异常
+ """
+ account = self.account
+ code = self.code
+
+ sms = SMS()
+ sms.send_verify_code(account, code)
+ logger.info(f'Send sms verify code: account={account} code={code}')
+
+ def verify(self, code):
+ right = cache.get(self.key)
+ if not right:
+ raise CodeExpired
+
+ if right != code:
+ raise CodeError
+
+ self.clear()
+ return True
+
+ def ttl(self):
+ return cache.ttl(self.key)
+
+ def get_code(self):
+ return cache.get(self.key)
diff --git a/apps/authentication/templates/authentication/login_otp.html b/apps/authentication/templates/authentication/login_otp.html
index f17451949..858c2737d 100644
--- a/apps/authentication/templates/authentication/login_otp.html
+++ b/apps/authentication/templates/authentication/login_otp.html
@@ -9,24 +9,60 @@
{% block content %}
+
{% endblock %}
diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py
index d8613adf4..2dea0da7b 100644
--- a/apps/authentication/urls/api_urls.py
+++ b/apps/authentication/urls/api_urls.py
@@ -27,7 +27,9 @@ urlpatterns = [
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('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
+ path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
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//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py
index f8571d6d7..594e4e68a 100644
--- a/apps/authentication/utils.py
+++ b/apps/authentication/utils.py
@@ -4,7 +4,6 @@ import base64
from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from Cryptodome import Random
-
from common.utils import get_logger
logger = get_logger(__file__)
diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py
index f3c2602cb..9347ff97c 100644
--- a/apps/authentication/views/mfa.py
+++ b/apps/authentication/views/mfa.py
@@ -3,6 +3,8 @@
from __future__ import unicode_literals
from django.views.generic.edit import FormView
+from django.utils.translation import gettext_lazy as _
+from django.conf import settings
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view
@@ -18,12 +20,14 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
redirect_field_name = 'next'
def form_valid(self, form):
- otp_code = form.cleaned_data.get('otp_code')
+ otp_code = form.cleaned_data.get('code')
+ mfa_type = form.cleaned_data.get('mfa_type')
+
try:
- self.check_user_mfa(otp_code)
+ self.check_user_mfa(otp_code, mfa_type)
return redirect_to_guard_view()
except (errors.MFAFailedError, errors.BlockMFAError) as e:
- form.add_error('otp_code', e.msg)
+ form.add_error('code', e.msg)
return super().form_invalid(form)
except Exception as e:
logger.error(e)
@@ -31,3 +35,28 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
traceback.print_exception()
return redirect_to_guard_view()
+ def get_context_data(self, **kwargs):
+ user = self.get_user_from_session()
+ context = {
+ 'methods': [
+ {
+ 'name': 'otp',
+ 'label': _('One-time password'),
+ 'enable': bool(user.otp_secret_key),
+ 'selected': False,
+ },
+ {
+ 'name': 'sms',
+ 'label': _('SMS'),
+ 'enable': bool(user.phone) and settings.AUTH_SMS,
+ 'selected': False,
+ },
+ ]
+ }
+
+ for item in context['methods']:
+ if item['enable']:
+ item['selected'] = True
+ break
+ context.update(kwargs)
+ return context
diff --git a/apps/common/message/backends/dingtalk/__init__.py b/apps/common/message/backends/dingtalk/__init__.py
index e98bdee04..1c8a5b59c 100644
--- a/apps/common/message/backends/dingtalk/__init__.py
+++ b/apps/common/message/backends/dingtalk/__init__.py
@@ -2,9 +2,12 @@ import time
import hmac
import base64
+from common.utils import get_logger
from common.message.backends.utils import digest, as_request
from common.message.backends.mixin import BaseRequest
+logger = get_logger(__file__)
+
def sign(secret, data):
@@ -160,6 +163,7 @@ class DingTalk:
}
}
}
+ logger.info(f'Dingtalk send text: user_ids={user_ids} msg={msg}')
data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True)
return data
diff --git a/apps/common/message/backends/feishu/__init__.py b/apps/common/message/backends/feishu/__init__.py
index 7f70fd35d..3bc67b1b5 100644
--- a/apps/common/message/backends/feishu/__init__.py
+++ b/apps/common/message/backends/feishu/__init__.py
@@ -106,6 +106,7 @@ class FeiShu(RequestMixin):
body['receive_id'] = user_id
try:
+ logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}')
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
except APIException as e:
# 只处理可预知的错误
diff --git a/apps/common/message/backends/sms/__init__.py b/apps/common/message/backends/sms/__init__.py
new file mode 100644
index 000000000..a0662ba10
--- /dev/null
+++ b/apps/common/message/backends/sms/__init__.py
@@ -0,0 +1,84 @@
+from collections import OrderedDict
+import importlib
+
+from django.utils.translation import gettext_lazy as _
+from django.db.models import TextChoices
+from django.conf import settings
+
+from common.utils import get_logger
+from common.exceptions import JMSException
+
+logger = get_logger(__file__)
+
+
+class SMS_MESSAGE(TextChoices):
+ """
+ 定义短信的各种消息类型,会存到类似 `ALIBABA_SMS_SIGN_AND_TEMPLATES` settings 里
+
+ {
+ 'verification_code': {'sign_name': 'Jumpserver', 'template_code': 'SMS_222870834'},
+ ...
+ }
+ """
+
+ """
+ 验证码签名和模板。模板例子:
+ `您的验证码:${code},您正进行身份验证,打死不告诉别人!`
+ 其中必须包含 `code` 变量
+ """
+ VERIFICATION_CODE = 'verification_code'
+
+ def get_sign_and_tmpl(self, config: dict):
+ try:
+ data = config[self]
+ return data['sign_name'], data['template_code']
+ except KeyError as e:
+ raise JMSException(
+ code=f'{settings.SMS_BACKEND}_sign_and_tmpl_bad',
+ detail=_('SMS sign and template bad: {}').format(e)
+ )
+
+
+class BACKENDS(TextChoices):
+ ALIBABA = 'alibaba', _('Alibaba')
+ TENCENT = 'tencent', _('Tencent')
+
+
+class BaseSMSClient:
+ """
+ 短信终端的基类
+ """
+
+ SIGN_AND_TMPL_SETTING_FIELD: str
+
+ @property
+ def sign_and_tmpl(self):
+ return getattr(settings, self.SIGN_AND_TMPL_SETTING_FIELD, {})
+
+ @classmethod
+ def new_from_settings(cls):
+ raise NotImplementedError
+
+ def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
+ raise NotImplementedError
+
+
+class SMS:
+ client: BaseSMSClient
+
+ def __init__(self, backend=None):
+ m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__)
+ self.client = m.client.new_from_settings()
+
+ def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
+ return self.client.send_sms(
+ phone_numbers=phone_numbers,
+ sign_name=sign_name,
+ template_code=template_code,
+ template_param=template_param,
+ **kwargs
+ )
+
+ def send_verify_code(self, phone_number, code):
+ sign_name, template_code = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(self.client.sign_and_tmpl)
+ return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code))
diff --git a/apps/common/message/backends/sms/alibaba.py b/apps/common/message/backends/sms/alibaba.py
new file mode 100644
index 000000000..b664f4401
--- /dev/null
+++ b/apps/common/message/backends/sms/alibaba.py
@@ -0,0 +1,61 @@
+import json
+
+from django.utils.translation import gettext_lazy as _
+from django.conf import settings
+from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client
+from alibabacloud_tea_openapi import models as open_api_models
+from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models
+from Tea.exceptions import TeaException
+
+from common.utils import get_logger
+from common.exceptions import JMSException
+from . import BaseSMSClient
+
+logger = get_logger(__file__)
+
+
+class AlibabaSMS(BaseSMSClient):
+ SIGN_AND_TMPL_SETTING_FIELD = 'ALIBABA_SMS_SIGN_AND_TEMPLATES'
+
+ @classmethod
+ def new_from_settings(cls):
+ return cls(
+ access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
+ access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET
+ )
+
+ def __init__(self, access_key_id: str, access_key_secret: str):
+ config = open_api_models.Config(
+ # 您的AccessKey ID,
+ access_key_id=access_key_id,
+ # 您的AccessKey Secret,
+ access_key_secret=access_key_secret
+ )
+ # 访问的域名
+ config.endpoint = 'dysmsapi.aliyuncs.com'
+ self.client = Dysmsapi20170525Client(config)
+
+ def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs):
+ phone_numbers_str = ','.join(phone_numbers)
+ send_sms_request = dysmsapi_20170525_models.SendSmsRequest(
+ phone_numbers=phone_numbers_str, sign_name=sign_name,
+ template_code=template_code, template_param=json.dumps(template_param)
+ )
+ try:
+ logger.info(f'Alibaba sms send: '
+ f'phone_numbers={phone_numbers} '
+ f'sign_name={sign_name} '
+ f'template_code={template_code} '
+ f'template_param={template_param}')
+ response = self.client.send_sms(send_sms_request)
+ # 这里只判断是否成功,失败抛出异常
+ if response.body.code != 'OK':
+ raise JMSException(detail=response.body.message, code=response.body.code)
+ except TeaException as e:
+ if e.code == 'SignatureDoesNotMatch':
+ raise JMSException(code=e.code, detail=_('Signature does not match'))
+ raise JMSException(code=e.code, detail=e.message)
+ return response
+
+
+client = AlibabaSMS
diff --git a/apps/common/message/backends/sms/tencent.py b/apps/common/message/backends/sms/tencent.py
new file mode 100644
index 000000000..6f796bb15
--- /dev/null
+++ b/apps/common/message/backends/sms/tencent.py
@@ -0,0 +1,90 @@
+import json
+from collections import OrderedDict
+
+from django.conf import settings
+from common.exceptions import JMSException
+from common.utils import get_logger
+from tencentcloud.common import credential
+from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
+# 导入对应产品模块的client models。
+from tencentcloud.sms.v20210111 import sms_client, models
+# 导入可选配置类
+from tencentcloud.common.profile.client_profile import ClientProfile
+from tencentcloud.common.profile.http_profile import HttpProfile
+from . import BaseSMSClient
+
+logger = get_logger(__file__)
+
+
+class TencentSMS(BaseSMSClient):
+ """
+ https://cloud.tencent.com/document/product/382/43196#.E5.8F.91.E9.80.81.E7.9F.AD.E4.BF.A1
+ """
+ SIGN_AND_TMPL_SETTING_FIELD = 'TENCENT_SMS_SIGN_AND_TEMPLATES'
+
+ @classmethod
+ def new_from_settings(cls):
+ return cls(
+ secret_id=settings.TENCENT_SECRET_ID,
+ secret_key=settings.TENCENT_SECRET_KEY,
+ sdkappid=settings.TENCENT_SDKAPPID
+ )
+
+ def __init__(self, secret_id: str, secret_key: str, sdkappid: str):
+ self.sdkappid = sdkappid
+
+ cred = credential.Credential(secret_id, secret_key)
+ httpProfile = HttpProfile()
+ httpProfile.reqMethod = "POST" # post请求(默认为post请求)
+ httpProfile.reqTimeout = 30 # 请求超时时间,单位为秒(默认60秒)
+ httpProfile.endpoint = "sms.tencentcloudapi.com"
+
+ clientProfile = ClientProfile()
+ clientProfile.signMethod = "TC3-HMAC-SHA256" # 指定签名算法
+ clientProfile.language = "en-US"
+ clientProfile.httpProfile = httpProfile
+ self.client = sms_client.SmsClient(cred, "ap-guangzhou", clientProfile)
+
+ def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: OrderedDict, **kwargs):
+ try:
+ req = models.SendSmsRequest()
+ # 基本类型的设置:
+ # SDK采用的是指针风格指定参数,即使对于基本类型你也需要用指针来对参数赋值。
+ # SDK提供对基本类型的指针引用封装函数
+ # 帮助链接:
+ # 短信控制台: https://console.cloud.tencent.com/smsv2
+ # sms helper: https://cloud.tencent.com/document/product/382/3773
+
+ # 短信应用ID: 短信SdkAppId在 [短信控制台] 添加应用后生成的实际SdkAppId,示例如1400006666
+ req.SmsSdkAppId = self.sdkappid
+ # 短信签名内容: 使用 UTF-8 编码,必须填写已审核通过的签名,签名信息可登录 [短信控制台] 查看
+ req.SignName = sign_name
+ # 短信码号扩展号: 默认未开通,如需开通请联系 [sms helper]
+ req.ExtendCode = ""
+ # 用户的 session 内容: 可以携带用户侧 ID 等上下文信息,server 会原样返回
+ req.SessionContext = "Jumpserver"
+ # 国际/港澳台短信 senderid: 国内短信填空,默认未开通,如需开通请联系 [sms helper]
+ req.SenderId = ""
+ # 下发手机号码,采用 E.164 标准,+[国家或地区码][手机号]
+ # 示例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号,最多不要超过200个手机号
+ req.PhoneNumberSet = phone_numbers
+ # 模板 ID: 必须填写已审核通过的模板 ID。模板ID可登录 [短信控制台] 查看
+ req.TemplateId = template_code
+ # 模板参数: 若无模板参数,则设置为空
+ req.TemplateParamSet = list(template_param.values())
+ # 通过client对象调用DescribeInstances方法发起请求。注意请求方法名与请求对象是对应的。
+ # 返回的resp是一个DescribeInstancesResponse类的实例,与请求对象对应。
+ logger.info(f'Tencent sms send: '
+ f'phone_numbers={phone_numbers} '
+ f'sign_name={sign_name} '
+ f'template_code={template_code} '
+ f'template_param={template_param}')
+
+ resp = self.client.SendSms(req)
+
+ return resp
+ except TencentCloudSDKException as e:
+ raise JMSException(code=e.code, detail=e.message)
+
+
+client = TencentSMS
diff --git a/apps/common/message/backends/wecom/__init__.py b/apps/common/message/backends/wecom/__init__.py
index 661a8276c..8ba593d2c 100644
--- a/apps/common/message/backends/wecom/__init__.py
+++ b/apps/common/message/backends/wecom/__init__.py
@@ -115,6 +115,7 @@ class WeCom(RequestMixin):
},
**extra_params
}
+ logger.info(f'Wecom send text: users={users} msg={msg}')
data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False)
errcode = data['errcode']
diff --git a/apps/common/permissions.py b/apps/common/permissions.py
index 71dca0b3c..b0f217c1c 100644
--- a/apps/common/permissions.py
+++ b/apps/common/permissions.py
@@ -191,3 +191,11 @@ class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission):
return False
query_user = current_org.get_members().filter(id=query_user_id).first()
return bool(query_user)
+
+
+class OnlySuperUserCanList(IsValidUser):
+ def has_permission(self, request, view):
+ user = request.user
+ if view.action == 'list' and not user.is_superuser:
+ return False
+ return True
diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py
index 381eab090..bd4627bdb 100644
--- a/apps/jumpserver/conf.py
+++ b/apps/jumpserver/conf.py
@@ -243,6 +243,19 @@ class Config(dict):
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS
'LOGIN_REDIRECT_MSG_ENABLED': True,
+ 'AUTH_SMS': False,
+ 'SMS_BACKEND': '',
+ 'SMS_TEST_PHONE': '',
+
+ 'ALIBABA_ACCESS_KEY_ID': '',
+ 'ALIBABA_ACCESS_KEY_SECRET': '',
+ 'ALIBABA_SMS_SIGN_AND_TEMPLATES': {},
+
+ 'TENCENT_SECRET_ID': '',
+ 'TENCENT_SECRET_KEY': '',
+ 'TENCENT_SDKAPPID': '',
+ 'TENCENT_SMS_SIGN_AND_TEMPLATES': {},
+
'OTP_VALID_WINDOW': 2,
'OTP_ISSUER_NAME': 'JumpServer',
'EMAIL_SUFFIX': 'example.com',
diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py
index d8e96e673..e06ec9028 100644
--- a/apps/jumpserver/settings/auth.py
+++ b/apps/jumpserver/settings/auth.py
@@ -122,6 +122,22 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
+# SMS auth
+AUTH_SMS = CONFIG.AUTH_SMS
+SMS_BACKEND = CONFIG.SMS_BACKEND
+SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE
+
+# Alibaba
+ALIBABA_ACCESS_KEY_ID = CONFIG.ALIBABA_ACCESS_KEY_ID
+ALIBABA_ACCESS_KEY_SECRET = CONFIG.ALIBABA_ACCESS_KEY_SECRET
+ALIBABA_SMS_SIGN_AND_TEMPLATES = CONFIG.ALIBABA_SMS_SIGN_AND_TEMPLATES
+
+# TENCENT
+TENCENT_SECRET_ID = CONFIG.TENCENT_SECRET_ID
+TENCENT_SECRET_KEY = CONFIG.TENCENT_SECRET_KEY
+TENCENT_SDKAPPID = CONFIG.TENCENT_SDKAPPID
+TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES
+
# Other setting
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
diff --git a/apps/notifications/api/notifications.py b/apps/notifications/api/notifications.py
index 5c726e201..3b58d3edc 100644
--- a/apps/notifications/api/notifications.py
+++ b/apps/notifications/api/notifications.py
@@ -1,18 +1,18 @@
-from django.http import Http404
-from rest_framework.mixins import ListModelMixin, UpdateModelMixin
+from rest_framework.mixins import ListModelMixin, UpdateModelMixin, RetrieveModelMixin
from rest_framework.views import APIView
from rest_framework.response import Response
-from rest_framework import status
from common.drf.api import JMSGenericViewSet
+from common.permissions import IsObjectOwner, IsSuperUser, OnlySuperUserCanList
from notifications.notifications import system_msgs
-from notifications.models import SystemMsgSubscription
+from notifications.models import SystemMsgSubscription, UserMsgSubscription
from notifications.backends import BACKEND
from notifications.serializers import (
- SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer
+ SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer,
+ UserMsgSubscriptionSerializer,
)
-__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet')
+__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet', 'UserMsgSubscriptionViewSet')
class BackendListView(APIView):
@@ -70,3 +70,13 @@ class SystemMsgSubscriptionViewSet(ListModelMixin,
serializer = self.get_serializer(data, many=True)
return Response(data=serializer.data)
+
+
+class UserMsgSubscriptionViewSet(ListModelMixin,
+ RetrieveModelMixin,
+ UpdateModelMixin,
+ JMSGenericViewSet):
+ lookup_field = 'user_id'
+ queryset = UserMsgSubscription.objects.all()
+ serializer_class = UserMsgSubscriptionSerializer
+ permission_classes = (IsObjectOwner | IsSuperUser, OnlySuperUserCanList)
diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py
index 11a95cf40..a991b9567 100644
--- a/apps/notifications/backends/__init__.py
+++ b/apps/notifications/backends/__init__.py
@@ -1,11 +1,9 @@
+import importlib
+
from django.utils.translation import gettext_lazy as _
from django.db import models
-from .dingtalk import DingTalk
-from .email import Email
-from .site_msg import SiteMessage
-from .wecom import WeCom
-from .feishu import FeiShu
+client_name_mapper = {}
class BACKEND(models.TextChoices):
@@ -14,17 +12,11 @@ class BACKEND(models.TextChoices):
DINGTALK = 'dingtalk', _('DingTalk')
SITE_MSG = 'site_msg', _('Site message')
FEISHU = 'feishu', _('FeiShu')
+ SMS = 'sms', _('SMS')
@property
def client(self):
- client = {
- self.EMAIL: Email,
- self.WECOM: WeCom,
- self.DINGTALK: DingTalk,
- self.SITE_MSG: SiteMessage,
- self.FEISHU: FeiShu,
- }[self]
- return client
+ return client_name_mapper[self]
def get_account(self, user):
return self.client.get_account(user)
@@ -37,3 +29,8 @@ class BACKEND(models.TextChoices):
def filter_enable_backends(cls, backends):
enable_backends = [b for b in backends if cls(b).is_enable]
return enable_backends
+
+
+for b in BACKEND:
+ m = importlib.import_module(f'.{b}', __package__)
+ client_name_mapper[b] = m.backend
diff --git a/apps/notifications/backends/dingtalk.py b/apps/notifications/backends/dingtalk.py
index 83add673e..ba72091a4 100644
--- a/apps/notifications/backends/dingtalk.py
+++ b/apps/notifications/backends/dingtalk.py
@@ -14,6 +14,9 @@ class DingTalk(BackendBase):
agentid=settings.DINGTALK_AGENTID
)
- def send_msg(self, users, msg):
+ def send_msg(self, users, message, subject=None):
accounts, __, __ = self.get_accounts(users)
- return self.dingtalk.send_text(accounts, msg)
+ return self.dingtalk.send_text(accounts, message)
+
+
+backend = DingTalk
diff --git a/apps/notifications/backends/email.py b/apps/notifications/backends/email.py
index 4e1c27322..390da151a 100644
--- a/apps/notifications/backends/email.py
+++ b/apps/notifications/backends/email.py
@@ -8,7 +8,10 @@ class Email(BackendBase):
account_field = 'email'
is_enable_field_in_settings = 'EMAIL_HOST_USER'
- def send_msg(self, users, subject, message):
+ def send_msg(self, users, message, subject):
from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER
accounts, __, __ = self.get_accounts(users)
send_mail(subject, message, from_email, accounts, html_message=message)
+
+
+backend = Email
diff --git a/apps/notifications/backends/feishu.py b/apps/notifications/backends/feishu.py
index 90547299c..434898c8a 100644
--- a/apps/notifications/backends/feishu.py
+++ b/apps/notifications/backends/feishu.py
@@ -14,6 +14,9 @@ class FeiShu(BackendBase):
app_secret=settings.FEISHU_APP_SECRET
)
- def send_msg(self, users, msg):
+ def send_msg(self, users, message, subject=None):
accounts, __, __ = self.get_accounts(users)
- return self.client.send_text(accounts, msg)
+ return self.client.send_text(accounts, message)
+
+
+backend = FeiShu
diff --git a/apps/notifications/backends/site_msg.py b/apps/notifications/backends/site_msg.py
index 0f7468f48..faf539d17 100644
--- a/apps/notifications/backends/site_msg.py
+++ b/apps/notifications/backends/site_msg.py
@@ -5,10 +5,13 @@ from .base import BackendBase
class SiteMessage(BackendBase):
account_field = 'id'
- def send_msg(self, users, subject, message):
+ def send_msg(self, users, message, subject):
accounts, __, __ = self.get_accounts(users)
Client.send_msg(subject, message, user_ids=accounts)
@classmethod
def is_enable(cls):
return True
+
+
+backend = SiteMessage
diff --git a/apps/notifications/backends/sms.py b/apps/notifications/backends/sms.py
new file mode 100644
index 000000000..a4deb02c7
--- /dev/null
+++ b/apps/notifications/backends/sms.py
@@ -0,0 +1,25 @@
+from django.conf import settings
+
+from common.message.backends.sms.alibaba import AlibabaSMS as Client
+from .base import BackendBase
+
+
+class SMS(BackendBase):
+ account_field = 'phone'
+ is_enable_field_in_settings = 'AUTH_SMS'
+
+ def __init__(self):
+ """
+ 暂时只对接阿里,之后再扩展
+ """
+ self.client = Client(
+ access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
+ access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET
+ )
+
+ def send_msg(self, users, sign_name: str, template_code: str, template_param: dict):
+ accounts, __, __ = self.get_accounts(users)
+ return self.client.send_sms(accounts, sign_name, template_code, template_param)
+
+
+backend = SMS
diff --git a/apps/notifications/backends/wecom.py b/apps/notifications/backends/wecom.py
index 80b6f1a22..988c904c2 100644
--- a/apps/notifications/backends/wecom.py
+++ b/apps/notifications/backends/wecom.py
@@ -15,6 +15,9 @@ class WeCom(BackendBase):
agentid=settings.WECOM_AGENTID
)
- def send_msg(self, users, msg):
+ def send_msg(self, users, message, subject=None):
accounts, __, __ = self.get_accounts(users)
- return self.wecom.send_text(accounts, msg)
+ return self.wecom.send_text(accounts, message)
+
+
+backend = WeCom
diff --git a/apps/notifications/migrations/0002_auto_20210823_1619.py b/apps/notifications/migrations/0002_auto_20210823_1619.py
new file mode 100644
index 000000000..26230e9d8
--- /dev/null
+++ b/apps/notifications/migrations/0002_auto_20210823_1619.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.12 on 2021-08-23 08:19
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('notifications', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='usermsgsubscription',
+ name='message_type',
+ ),
+ migrations.AlterField(
+ model_name='usermsgsubscription',
+ name='user',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL, unique=True),
+ ),
+ ]
diff --git a/apps/notifications/migrations/0003_init_user_msg_subscription.py b/apps/notifications/migrations/0003_init_user_msg_subscription.py
new file mode 100644
index 000000000..2c684c86a
--- /dev/null
+++ b/apps/notifications/migrations/0003_init_user_msg_subscription.py
@@ -0,0 +1,43 @@
+# Generated by Django 3.1.12 on 2021-08-23 07:52
+
+from django.db import migrations
+
+
+def init_user_msg_subscription(apps, schema_editor):
+ UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription')
+ User = apps.get_model('users', 'User')
+
+ to_create = []
+ users = User.objects.all()
+ for user in users:
+ receive_backends = []
+
+ receive_backends.append('site_msg')
+
+ if user.email:
+ receive_backends.append('email')
+
+ if user.wecom_id:
+ receive_backends.append('wecom')
+
+ if user.dingtalk_id:
+ receive_backends.append('dingtalk')
+
+ if user.feishu_id:
+ receive_backends.append('feishu')
+
+ to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends))
+ UserMsgSubscription.objects.bulk_create(to_create)
+ print(f'\n Init user message subscription: {len(to_create)}')
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0036_user_feishu_id'),
+ ('notifications', '0002_auto_20210823_1619'),
+ ]
+
+ operations = [
+ migrations.RunPython(init_user_msg_subscription)
+ ]
diff --git a/apps/notifications/models/notification.py b/apps/notifications/models/notification.py
index 94bd1ad7d..eb63ab648 100644
--- a/apps/notifications/models/notification.py
+++ b/apps/notifications/models/notification.py
@@ -6,12 +6,11 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription')
class UserMsgSubscription(JMSModel):
- message_type = models.CharField(max_length=128)
- user = models.ForeignKey('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE)
+ user = models.ForeignKey('users.User', unique=True, related_name='user_msg_subscriptions', on_delete=models.CASCADE)
receive_backends = models.JSONField(default=list)
def __str__(self):
- return f'{self.message_type}'
+ return f'{self.user} subscription: {self.receive_backends}'
class SystemMsgSubscription(JMSModel):
diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py
index cac467734..2b051cc58 100644
--- a/apps/notifications/notifications.py
+++ b/apps/notifications/notifications.py
@@ -1,12 +1,14 @@
from typing import Iterable
import traceback
from itertools import chain
+from collections import defaultdict
-from django.db.utils import ProgrammingError
from celery import shared_task
+from common.utils import lazyproperty
+from users.models import User
from notifications.backends import BACKEND
-from .models import SystemMsgSubscription
+from .models import SystemMsgSubscription, UserMsgSubscription
__all__ = ('SystemMessage', 'UserMessage')
@@ -69,37 +71,49 @@ class Message(metaclass=MessageType):
for backend in backends:
try:
backend = BACKEND(backend)
+ if not backend.is_enable:
+ continue
get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg)
- msg = get_msg_method()
+
+ try:
+ msg = get_msg_method()
+ except NotImplementedError:
+ continue
+
client = backend.client()
- if isinstance(msg, dict):
- client.send_msg(users, **msg)
- else:
- client.send_msg(users, msg)
+ client.send_msg(users, **msg)
except:
traceback.print_exc()
- def get_common_msg(self) -> str:
+ def get_common_msg(self) -> dict:
raise NotImplementedError
- def get_dingtalk_msg(self) -> str:
+ @lazyproperty
+ def common_msg(self) -> dict:
return self.get_common_msg()
- def get_wecom_msg(self) -> str:
- return self.get_common_msg()
+ # --------------------------------------------------------------
+ # 支持不同发送消息的方式定义自己的消息内容,比如有些支持 html 标签
+ def get_dingtalk_msg(self) -> dict:
+ return self.common_msg
+
+ def get_wecom_msg(self) -> dict:
+ return self.common_msg
+
+ def get_feishu_msg(self) -> dict:
+ return self.common_msg
def get_email_msg(self) -> dict:
- msg = self.get_common_msg()
- subject = f'{msg[:80]} ...' if len(msg) >= 80 else msg
- return {
- 'subject': subject,
- 'message': msg
- }
+ return self.common_msg
def get_site_msg_msg(self) -> dict:
- return self.get_email_msg()
+ return self.common_msg
+
+ def get_sms_msg(self) -> dict:
+ raise NotImplementedError
+ # --------------------------------------------------------------
class SystemMessage(Message):
@@ -125,4 +139,16 @@ class SystemMessage(Message):
class UserMessage(Message):
- pass
+ user: User
+
+ def __init__(self, user):
+ self.user = user
+
+ def publish(self):
+ """
+ 发送消息到每个用户配置的接收方式上
+ """
+
+ sub = UserMsgSubscription.objects.get(user=self.user)
+
+ self.send_msg([self.user], sub.receive_backends)
diff --git a/apps/notifications/serializers/notifications.py b/apps/notifications/serializers/notifications.py
index 7415d46f7..191d90a77 100644
--- a/apps/notifications/serializers/notifications.py
+++ b/apps/notifications/serializers/notifications.py
@@ -1,7 +1,7 @@
from rest_framework import serializers
from common.drf.serializers import BulkModelSerializer
-from notifications.models import SystemMsgSubscription
+from notifications.models import SystemMsgSubscription, UserMsgSubscription
class SystemMsgSubscriptionSerializer(BulkModelSerializer):
@@ -27,3 +27,11 @@ class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer):
category = serializers.CharField()
category_label = serializers.CharField()
children = SystemMsgSubscriptionSerializer(many=True)
+
+
+class UserMsgSubscriptionSerializer(BulkModelSerializer):
+ receive_backends = serializers.ListField(child=serializers.CharField(), read_only=False)
+
+ class Meta:
+ model = UserMsgSubscription
+ fields = ('user_id', 'receive_backends',)
diff --git a/apps/notifications/signals_handler.py b/apps/notifications/signals_handler.py
index 451377557..019d2a3da 100644
--- a/apps/notifications/signals_handler.py
+++ b/apps/notifications/signals_handler.py
@@ -6,14 +6,14 @@ from django.utils.functional import LazyObject
from django.db.models.signals import post_save
from django.db.models.signals import post_migrate
from django.dispatch import receiver
-from django.db.utils import DEFAULT_DB_ALIAS
-from django.apps import apps as global_apps
from django.apps import AppConfig
+from notifications.backends import BACKEND
+from users.models import User
from common.utils.connection import RedisPubSub
from common.utils import get_logger
from common.decorator import on_transaction_commit
-from .models import SiteMessage, SystemMsgSubscription
+from .models import SiteMessage, SystemMsgSubscription, UserMsgSubscription
from .notifications import SystemMessage
@@ -82,3 +82,13 @@ def create_system_messages(app_config: AppConfig, **kwargs):
logger.info(f'Create SystemMsgSubscription: package={app_config.module.__package__} type={message_type}')
except ModuleNotFoundError:
pass
+
+
+@receiver(post_save, sender=User)
+def on_user_post_save(sender, instance, created, **kwargs):
+ if created:
+ receive_backends = []
+ for backend in BACKEND:
+ if backend.get_account(instance):
+ receive_backends.append(backend)
+ UserMsgSubscription.objects.create(user=instance, receive_backends=receive_backends)
diff --git a/apps/notifications/site_msg.py b/apps/notifications/site_msg.py
index 1a5c9dc23..6e3f45f9d 100644
--- a/apps/notifications/site_msg.py
+++ b/apps/notifications/site_msg.py
@@ -2,9 +2,12 @@ from django.db.models import F
from django.db import transaction
from common.utils.timezone import now
+from common.utils import get_logger
from users.models import User
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
+logger = get_logger(__file__)
+
class SiteMessageUtil:
@@ -14,6 +17,11 @@ class SiteMessageUtil:
if not any((user_ids, group_ids, is_broadcast)):
raise ValueError('No recipient is specified')
+ logger.info(f'Site message send: '
+ f'user_ids={user_ids} '
+ f'group_ids={group_ids} '
+ f'subject={subject} '
+ f'message={message}')
with transaction.atomic():
site_msg = SiteMessageModel.objects.create(
subject=subject, message=message,
diff --git a/apps/notifications/urls/api_urls.py b/apps/notifications/urls/api_urls.py
index 60aaee873..14ed78e52 100644
--- a/apps/notifications/urls/api_urls.py
+++ b/apps/notifications/urls/api_urls.py
@@ -8,6 +8,7 @@ app_name = 'notifications'
router = BulkRouter()
router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription')
+router.register('user-msg-subscription', api.UserMsgSubscriptionViewSet, 'user-msg-subscription')
router.register('site-message', api.SiteMessageViewSet, 'site-message')
urlpatterns = [
diff --git a/apps/ops/notifications.py b/apps/ops/notifications.py
index d39805b8e..a8c659108 100644
--- a/apps/ops/notifications.py
+++ b/apps/ops/notifications.py
@@ -18,7 +18,10 @@ class ServerPerformanceMessage(SystemMessage):
self._msg = msg
def get_common_msg(self):
- return self._msg
+ return {
+ 'subject': self._msg[:80],
+ 'message': self._msg
+ }
@classmethod
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py
index 1ef4336e5..65438dda1 100644
--- a/apps/settings/api/__init__.py
+++ b/apps/settings/api/__init__.py
@@ -5,3 +5,6 @@ from .dingtalk import *
from .feishu import *
from .public import *
from .email import *
+from .alibaba_sms import *
+from .tencent_sms import *
+from .sms import *
diff --git a/apps/settings/api/alibaba_sms.py b/apps/settings/api/alibaba_sms.py
new file mode 100644
index 000000000..c7f42f110
--- /dev/null
+++ b/apps/settings/api/alibaba_sms.py
@@ -0,0 +1,58 @@
+from rest_framework.views import Response
+from rest_framework.generics import GenericAPIView
+from rest_framework.exceptions import APIException
+from rest_framework import status
+from django.utils.translation import gettext_lazy as _
+
+from common.message.backends.sms import SMS_MESSAGE
+from common.message.backends.sms.alibaba import AlibabaSMS
+from settings.models import Setting
+from common.permissions import IsSuperUser
+from common.exceptions import JMSException
+
+from .. import serializers
+
+
+class AlibabaSMSTestingAPI(GenericAPIView):
+ permission_classes = (IsSuperUser,)
+ serializer_class = serializers.AlibabaSMSSettingSerializer
+
+ def post(self, request):
+ serializer = self.serializer_class(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ alibaba_access_key_id = serializer.validated_data['ALIBABA_ACCESS_KEY_ID']
+ alibaba_access_key_secret = serializer.validated_data.get('ALIBABA_ACCESS_KEY_SECRET')
+ alibaba_sms_sign_and_tmpl = serializer.validated_data['ALIBABA_SMS_SIGN_AND_TEMPLATES']
+ test_phone = serializer.validated_data.get('SMS_TEST_PHONE')
+
+ if not test_phone:
+ raise JMSException(code='test_phone_required', detail=_('test_phone is required'))
+
+ if not alibaba_access_key_secret:
+ secret = Setting.objects.filter(name='ALIBABA_ACCESS_KEY_SECRET').first()
+ if secret:
+ alibaba_access_key_secret = secret.cleaned_value
+
+ alibaba_access_key_secret = alibaba_access_key_secret or ''
+
+ try:
+ client = AlibabaSMS(
+ access_key_id=alibaba_access_key_id,
+ access_key_secret=alibaba_access_key_secret
+ )
+ sign, tmpl = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(alibaba_sms_sign_and_tmpl)
+
+ client.send_sms(
+ phone_numbers=[test_phone],
+ sign_name=sign,
+ template_code=tmpl,
+ template_param={'code': 'test'}
+ )
+ return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')})
+ except APIException as e:
+ try:
+ error = e.detail['errmsg']
+ except:
+ error = e.detail
+ return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error})
diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py
index db7fdef4c..4dda51408 100644
--- a/apps/settings/api/settings.py
+++ b/apps/settings/api/settings.py
@@ -34,6 +34,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
'sso': serializers.SSOSettingSerializer,
'clean': serializers.CleaningSerializer,
'other': serializers.OtherSettingSerializer,
+ 'alibaba': serializers.AlibabaSMSSettingSerializer,
+ 'tencent': serializers.TencentSMSSettingSerializer,
}
def get_serializer_class(self):
diff --git a/apps/settings/api/sms.py b/apps/settings/api/sms.py
new file mode 100644
index 000000000..194dbc608
--- /dev/null
+++ b/apps/settings/api/sms.py
@@ -0,0 +1,22 @@
+from rest_framework.generics import ListAPIView
+from rest_framework.response import Response
+
+from common.permissions import IsSuperUser
+from common.message.backends.sms import BACKENDS
+from settings.serializers.sms import SMSBackendSerializer
+
+
+class SMSBackendAPI(ListAPIView):
+ permission_classes = (IsSuperUser,)
+ serializer_class = SMSBackendSerializer
+
+ def list(self, request, *args, **kwargs):
+ data = [
+ {
+ 'name': b,
+ 'label': b.label
+ }
+ for b in BACKENDS
+ ]
+
+ return Response(data)
diff --git a/apps/settings/api/tencent_sms.py b/apps/settings/api/tencent_sms.py
new file mode 100644
index 000000000..27ad07327
--- /dev/null
+++ b/apps/settings/api/tencent_sms.py
@@ -0,0 +1,63 @@
+from collections import OrderedDict
+
+from rest_framework.views import Response
+from rest_framework.generics import GenericAPIView
+from rest_framework.exceptions import APIException
+from rest_framework import status
+from django.utils.translation import gettext_lazy as _
+
+from common.message.backends.sms import SMS_MESSAGE
+from common.message.backends.sms.tencent import TencentSMS
+from settings.models import Setting
+from common.permissions import IsSuperUser
+from common.exceptions import JMSException
+
+from .. import serializers
+
+
+class TencentSMSTestingAPI(GenericAPIView):
+ permission_classes = (IsSuperUser,)
+ serializer_class = serializers.TencentSMSSettingSerializer
+
+ def post(self, request):
+ serializer = self.serializer_class(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ tencent_secret_id = serializer.validated_data['TENCENT_SECRET_ID']
+ tencent_secret_key = serializer.validated_data.get('TENCENT_SECRET_KEY')
+ tencent_sms_sign_and_tmpl = serializer.validated_data['TENCENT_SMS_SIGN_AND_TEMPLATES']
+ tencent_sdkappid = serializer.validated_data.get('TENCENT_SDKAPPID')
+
+ test_phone = serializer.validated_data.get('SMS_TEST_PHONE')
+
+ if not test_phone:
+ raise JMSException(code='test_phone_required', detail=_('test_phone is required'))
+
+ if not tencent_secret_key:
+ secret = Setting.objects.filter(name='TENCENT_SECRET_KEY').first()
+ if secret:
+ tencent_secret_key = secret.cleaned_value
+
+ tencent_secret_key = tencent_secret_key or ''
+
+ try:
+ client = TencentSMS(
+ secret_id=tencent_secret_id,
+ secret_key=tencent_secret_key,
+ sdkappid=tencent_sdkappid
+ )
+ sign, tmpl = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(tencent_sms_sign_and_tmpl)
+
+ client.send_sms(
+ phone_numbers=[test_phone],
+ sign_name=sign,
+ template_code=tmpl,
+ template_param=OrderedDict(code='test')
+ )
+ return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')})
+ except APIException as e:
+ try:
+ error = e.detail['errmsg']
+ except:
+ error = e.detail
+ return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error})
diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py
index e8040d316..4a2f77ebe 100644
--- a/apps/settings/serializers/auth/__init__.py
+++ b/apps/settings/serializers/auth/__init__.py
@@ -7,3 +7,4 @@ from .feishu import *
from .wecom import *
from .sso import *
from .base import *
+from .sms import *
diff --git a/apps/settings/serializers/auth/sms.py b/apps/settings/serializers/auth/sms.py
new file mode 100644
index 000000000..977dc76ea
--- /dev/null
+++ b/apps/settings/serializers/auth/sms.py
@@ -0,0 +1,51 @@
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import serializers
+
+from common.message.backends.sms import BACKENDS
+
+__all__ = ['AlibabaSMSSettingSerializer', 'TencentSMSSettingSerializer']
+
+
+class BaseSMSSettingSerializer(serializers.Serializer):
+ AUTH_SMS = serializers.BooleanField(default=False, label=_('Enable SMS'))
+ SMS_TEST_PHONE = serializers.CharField(max_length=256, required=False, label=_('Test phone'))
+
+ def to_representation(self, instance):
+ data = super().to_representation(instance)
+ data['SMS_BACKEND'] = self.fields['SMS_BACKEND'].default
+ return data
+
+
+class AlibabaSMSSettingSerializer(BaseSMSSettingSerializer):
+ SMS_BACKEND = serializers.ChoiceField(choices=BACKENDS.choices, default=BACKENDS.ALIBABA)
+ ALIBABA_ACCESS_KEY_ID = serializers.CharField(max_length=256, required=True, label='AccessKeyId')
+ ALIBABA_ACCESS_KEY_SECRET = serializers.CharField(
+ max_length=256, required=False, label='AccessKeySecret', write_only=True)
+ ALIBABA_SMS_SIGN_AND_TEMPLATES = serializers.DictField(
+ label=_('Signatures and Templates'), required=True, help_text=_('''
+ Filling in JSON Data:
+ {
+ "verification_code": {
+ "sign_name": "",
+ "template_code": ""
+ }
+ }
+ ''')
+ )
+
+
+class TencentSMSSettingSerializer(BaseSMSSettingSerializer):
+ SMS_BACKEND = serializers.ChoiceField(choices=BACKENDS.choices, default=BACKENDS.TENCENT)
+ TENCENT_SECRET_ID = serializers.CharField(max_length=256, required=True, label='Secret id')
+ TENCENT_SECRET_KEY = serializers.CharField(max_length=256, required=False, label='Secret key', write_only=True)
+ TENCENT_SDKAPPID = serializers.CharField(max_length=256, required=True, label='SDK app id')
+ TENCENT_SMS_SIGN_AND_TEMPLATES = serializers.DictField(
+ label=_('Signatures and Templates'), required=True, help_text=_('''
+ Filling in JSON Data:
+ {
+ "verification_code": {
+ "sign_name": "",
+ "template_code": ""
+ }
+ }
+ '''))
diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py
index edd8a30a8..7baa19196 100644
--- a/apps/settings/serializers/settings.py
+++ b/apps/settings/serializers/settings.py
@@ -6,12 +6,14 @@ from .email import EmailSettingSerializer, EmailContentSettingSerializer
from .auth import (
LDAPSettingSerializer, OIDCSettingSerializer, KeycloakSettingSerializer,
CASSettingSerializer, RadiusSettingSerializer, FeiShuSettingSerializer,
- WeComSettingSerializer, DingTalkSettingSerializer
+ WeComSettingSerializer, DingTalkSettingSerializer, AlibabaSMSSettingSerializer,
+ TencentSMSSettingSerializer,
)
from .terminal import TerminalSettingSerializer
from .security import SecuritySettingSerializer
from .cleaning import CleaningSerializer
+
__all__ = [
'SettingsSerializer',
]
@@ -32,7 +34,9 @@ class SettingsSerializer(
KeycloakSettingSerializer,
CASSettingSerializer,
RadiusSettingSerializer,
- CleaningSerializer
+ CleaningSerializer,
+ AlibabaSMSSettingSerializer,
+ TencentSMSSettingSerializer,
):
# encrypt_fields 现在使用 write_only 来判断了
pass
diff --git a/apps/settings/serializers/sms.py b/apps/settings/serializers/sms.py
new file mode 100644
index 000000000..fa274a52a
--- /dev/null
+++ b/apps/settings/serializers/sms.py
@@ -0,0 +1,7 @@
+from django.utils.translation import ugettext_lazy as _
+from rest_framework import serializers
+
+
+class SMSBackendSerializer(serializers.Serializer):
+ name = serializers.CharField(max_length=256, required=True, label=_('Name'))
+ label = serializers.CharField(max_length=256, required=True, label=_('Label'))
diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py
index bd423611f..22825f4e8 100644
--- a/apps/settings/urls/api_urls.py
+++ b/apps/settings/urls/api_urls.py
@@ -16,6 +16,9 @@ urlpatterns = [
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'),
path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'),
path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-testing'),
+ path('alibaba/testing/', api.AlibabaSMSTestingAPI.as_view(), name='alibaba-sms-testing'),
+ path('tencent/testing/', api.TencentSMSTestingAPI.as_view(), name='tencent-sms-testing'),
+ path('sms/backend/', api.SMSBackendAPI.as_view(), name='sms-backend'),
path('setting/', api.SettingsApi.as_view(), name='settings-setting'),
path('public/', api.PublicSettingApi.as_view(), name='public-setting'),
diff --git a/apps/terminal/notifications.py b/apps/terminal/notifications.py
index e9c83135e..46ac5b18d 100644
--- a/apps/terminal/notifications.py
+++ b/apps/terminal/notifications.py
@@ -81,7 +81,12 @@ class CommandAlertMessage(CommandAlertMixin, SystemMessage):
return message
def get_common_msg(self):
- return self._get_message()
+ msg = self._get_message()
+
+ return {
+ 'subject': msg[:80],
+ 'message': msg
+ }
def get_email_msg(self):
command = self.command
@@ -140,9 +145,6 @@ class CommandExecutionAlert(CommandAlertMixin, SystemMessage):
return message
def get_common_msg(self):
- return self._get_message()
-
- def get_email_msg(self):
command = self.command
subject = _("Insecure Web Command Execution Alert: [%(name)s]") % {
diff --git a/apps/users/api/profile.py b/apps/users/api/profile.py
index 9dcb42515..02bdda07c 100644
--- a/apps/users/api/profile.py
+++ b/apps/users/api/profile.py
@@ -4,14 +4,13 @@ import uuid
from rest_framework import generics
from common.permissions import IsOrgAdmin
from rest_framework.permissions import IsAuthenticated
-from django.conf import settings
+from users.notifications import ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg
from common.permissions import (
IsCurrentUserOrReadOnly
)
from .. import serializers
from ..models import User
-from ..utils import send_reset_password_success_mail
from .mixins import UserQuerysetMixin
__all__ = [
@@ -29,11 +28,10 @@ class UserResetPasswordApi(UserQuerysetMixin, generics.UpdateAPIView):
def perform_update(self, serializer):
# Note: we are not updating the user object here.
# We just do the reset-password stuff.
- from ..utils import send_reset_password_mail
user = self.get_object()
user.password_raw = str(uuid.uuid4())
user.save()
- send_reset_password_mail(user)
+ ResetPasswordMsg(user).publish_async()
class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView):
@@ -41,11 +39,11 @@ class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView):
permission_classes = (IsOrgAdmin,)
def perform_update(self, serializer):
- from ..utils import send_reset_ssh_key_mail
user = self.get_object()
user.public_key = None
user.save()
- send_reset_ssh_key_mail(user)
+
+ ResetSSHKeyMsg(user).publish_async()
# 废弃
@@ -84,4 +82,4 @@ class UserPublicKeyApi(generics.RetrieveUpdateAPIView):
def perform_update(self, serializer):
super().perform_update(serializer)
- send_reset_password_success_mail(self.request, self.get_object())
+ ResetPasswordSuccessMsg(self.get_object(), self.request).publish_async()
diff --git a/apps/users/api/user.py b/apps/users/api/user.py
index a8fc233c6..f55a96964 100644
--- a/apps/users/api/user.py
+++ b/apps/users/api/user.py
@@ -8,6 +8,7 @@ from rest_framework.response import Response
from rest_framework_bulk import BulkModelViewSet
from django.db.models import Prefetch
+from users.notifications import ResetMFAMsg
from common.permissions import (
IsOrgAdmin, IsOrgAdminOrAppUser,
CanUpdateDeleteUser, IsSuperUser
@@ -16,7 +17,7 @@ from common.mixins import CommonApiMixin
from common.utils import get_logger
from orgs.utils import current_org
from orgs.models import ROLE as ORG_ROLE, OrganizationMember
-from users.utils import send_reset_mfa_mail, LoginBlockUtil, MFABlockUtils
+from users.utils import LoginBlockUtil, MFABlockUtils
from .. import serializers
from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer
from .mixins import UserQuerysetMixin
@@ -209,5 +210,6 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView):
if user.mfa_enabled:
user.reset_mfa()
user.save()
- send_reset_mfa_mail(user)
+
+ ResetMFAMsg(user).publish_async()
return Response({"msg": "success"})
diff --git a/apps/users/exceptions.py b/apps/users/exceptions.py
index ff873d3dc..e69e65966 100644
--- a/apps/users/exceptions.py
+++ b/apps/users/exceptions.py
@@ -8,3 +8,13 @@ class MFANotEnabled(JMSException):
status_code = status.HTTP_403_FORBIDDEN
default_code = 'mfa_not_enabled'
default_detail = _('MFA not enabled')
+
+
+class PhoneNotSet(JMSException):
+ default_code = 'phone_not_set'
+ default_detail = _('Phone not set')
+
+
+class MFAMethodNotSupport(JMSException):
+ default_code = 'mfa_not_support'
+ default_detail = _('MFA method not support')
diff --git a/apps/users/models/user.py b/apps/users/models/user.py
index f407b5886..d8d981f13 100644
--- a/apps/users/models/user.py
+++ b/apps/users/models/user.py
@@ -20,18 +20,24 @@ from django.shortcuts import reverse
from orgs.utils import current_org
from orgs.models import OrganizationMember, Organization
+from common.exceptions import JMSException
from common.utils import date_expired_default, get_logger, lazyproperty, random_string
from common import fields
from common.const import choices
from common.db.models import TextChoices
-from users.exceptions import MFANotEnabled
+from users.exceptions import MFANotEnabled, PhoneNotSet
from ..signals import post_user_change_password
-__all__ = ['User', 'UserPasswordHistory']
+__all__ = ['User', 'UserPasswordHistory', 'MFAType']
logger = get_logger(__file__)
+class MFAType(TextChoices):
+ OTP = 'otp', _('One-time password')
+ SMS_CODE = 'sms', _('SMS verify code')
+
+
class AuthMixin:
date_password_last_updated: datetime.datetime
is_local: bool
@@ -514,19 +520,52 @@ class MFAMixin:
from ..utils import check_otp_code
return check_otp_code(self.otp_secret_key, code)
- def check_mfa(self, code):
+ def check_mfa(self, code, mfa_type=MFAType.OTP):
if not self.mfa_enabled:
raise MFANotEnabled
- if settings.OTP_IN_RADIUS:
- return self.check_radius(code)
- else:
- return self.check_otp(code)
+ if mfa_type == MFAType.OTP:
+ if settings.OTP_IN_RADIUS:
+ return self.check_radius(code)
+ else:
+ return self.check_otp(code)
+ elif mfa_type == MFAType.SMS_CODE:
+ return self.check_sms_code(code)
+
+ def get_supported_mfa_types(self):
+ methods = []
+ if self.otp_secret_key:
+ methods.append(MFAType.OTP)
+ if self.phone:
+ methods.append(MFAType.SMS_CODE)
+ return methods
+
+ def check_sms_code(self, code):
+ from authentication.sms_verify_code import VerifyCodeUtil
+
+ if not self.phone:
+ raise PhoneNotSet
+
+ try:
+ util = VerifyCodeUtil(self.phone)
+ return util.verify(code)
+ except JMSException:
+ return False
+
+ def send_sms_code(self):
+ from authentication.sms_verify_code import VerifyCodeUtil
+
+ if not self.phone:
+ raise PhoneNotSet
+
+ util = VerifyCodeUtil(self.phone)
+ util.touch()
+ return util.timeout
def mfa_enabled_but_not_set(self):
if not self.mfa_enabled:
return False, None
- if self.mfa_is_otp() and not self.otp_secret_key:
+ if self.mfa_is_otp() and not self.otp_secret_key and not self.phone:
return True, reverse('authentication:user-otp-enable-start')
return False, None
diff --git a/apps/users/notifications.py b/apps/users/notifications.py
new file mode 100644
index 000000000..7d860ca14
--- /dev/null
+++ b/apps/users/notifications.py
@@ -0,0 +1,389 @@
+from datetime import datetime
+
+from django.utils.translation import ugettext as _
+
+from common.utils import reverse, get_request_ip_or_data, get_request_user_agent, lazyproperty
+from notifications.notifications import UserMessage
+
+
+class BaseUserMessage(UserMessage):
+ def get_text_msg(self) -> dict:
+ raise NotImplementedError
+
+ def get_html_msg(self) -> dict:
+ raise NotImplementedError
+
+ @lazyproperty
+ def text_msg(self) -> dict:
+ return self.get_text_msg()
+
+ @lazyproperty
+ def html_msg(self) -> dict:
+ return self.get_html_msg()
+
+ def get_dingtalk_msg(self) -> dict:
+ return self.text_msg
+
+ def get_wecom_msg(self) -> dict:
+ return self.text_msg
+
+ def get_feishu_msg(self) -> dict:
+ return self.text_msg
+
+ def get_email_msg(self) -> dict:
+ return self.html_msg
+
+ def get_site_msg_msg(self) -> dict:
+ return self.html_msg
+
+
+class ResetPasswordMsg(BaseUserMessage):
+ def get_text_msg(self) -> dict:
+ user = self.user
+ subject = _('Reset password')
+ message = _("""
+Hello %(name)s:
+Please click the link below to reset your password, if not your request, concern your account security
+
+Click here reset password 👇
+%(rest_password_url)s?token=%(rest_password_token)s
+
+This link is valid for 1 hour. After it expires,
+
+request new one 👇
+%(forget_password_url)s?email=%(email)s
+
+-------------------
+
+Login direct 👇
+%(login_url)s
+
+""") % {
+ 'name': user.name,
+ 'rest_password_url': reverse('authentication:reset-password', external=True),
+ 'rest_password_token': user.generate_reset_token(),
+ 'forget_password_url': reverse('authentication:forgot-password', external=True),
+ 'email': user.email,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+ def get_html_msg(self) -> dict:
+ user = self.user
+ subject = _('Reset password')
+ message = _("""
+ Hello %(name)s:
+
+ Please click the link below to reset your password, if not your request, concern your account security
+
+ Click here reset password
+
+ This link is valid for 1 hour. After it expires, request new one
+
+
+ ---
+
+
+ Login direct
+
+
+ """) % {
+ 'name': user.name,
+ 'rest_password_url': reverse('authentication:reset-password', external=True),
+ 'rest_password_token': user.generate_reset_token(),
+ 'forget_password_url': reverse('authentication:forgot-password', external=True),
+ 'email': user.email,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+
+class ResetPasswordSuccessMsg(BaseUserMessage):
+ def __init__(self, user, request):
+ super().__init__(user)
+ self.ip_address = get_request_ip_or_data(request)
+ self.browser = get_request_user_agent(request)
+
+ def get_text_msg(self) -> dict:
+ user = self.user
+
+ subject = _('Reset password success')
+ message = _("""
+
+Hi %(name)s:
+
+Your JumpServer password has just been successfully updated.
+
+If the password update was not initiated by you, your account may have security issues.
+It is recommended that you log on to the JumpServer immediately and change your password.
+
+If you have any questions, you can contact the administrator.
+
+-------------------
+
+
+IP Address: %(ip_address)s
+
+
+Browser: %(browser)s
+
+
+ """) % {
+ 'name': user.name,
+ 'ip_address': self.ip_address,
+ 'browser': self.browser,
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+ def get_html_msg(self) -> dict:
+ user = self.user
+
+ subject = _('Reset password success')
+ message = _("""
+
+ Hi %(name)s:
+
+
+
+
+ Your JumpServer password has just been successfully updated.
+
+
+
+ If the password update was not initiated by you, your account may have security issues.
+ It is recommended that you log on to the JumpServer immediately and change your password.
+
+
+
+ If you have any questions, you can contact the administrator.
+
+
+ ---
+
+
+ IP Address: %(ip_address)s
+
+
+ Browser: %(browser)s
+
+
+ """) % {
+ 'name': user.name,
+ 'ip_address': self.ip_address,
+ 'browser': self.browser,
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+
+class PasswordExpirationReminderMsg(BaseUserMessage):
+ def get_text_msg(self) -> dict:
+ user = self.user
+
+ subject = _('Security notice')
+ message = _("""
+Hello %(name)s:
+
+Your password will expire in %(date_password_expired)s,
+
+For your account security, please click on the link below to update your password in time
+
+Click here update password 👇
+%(update_password_url)s
+
+If your password has expired, please click 👇 to apply for a password reset email.
+%(forget_password_url)s?email=%(email)s
+
+-------------------
+
+Login direct 👇
+%(login_url)s
+
+ """) % {
+ 'name': user.name,
+ 'date_password_expired': datetime.fromtimestamp(datetime.timestamp(
+ user.date_password_expired)).strftime('%Y-%m-%d %H:%M'),
+ 'update_password_url': reverse('users:user-password-update', external=True),
+ 'forget_password_url': reverse('authentication:forgot-password', external=True),
+ 'email': user.email,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+ def get_html_msg(self) -> dict:
+ user = self.user
+
+ subject = _('Security notice')
+ message = _("""
+ Hello %(name)s:
+
+ Your password will expire in %(date_password_expired)s,
+
+ For your account security, please click on the link below to update your password in time
+
+ Click here update password
+
+ If your password has expired, please click
+ Password expired
+ to apply for a password reset email.
+
+
+ ---
+
+
+ Login direct
+
+
+ """) % {
+ 'name': user.name,
+ 'date_password_expired': datetime.fromtimestamp(datetime.timestamp(
+ user.date_password_expired)).strftime('%Y-%m-%d %H:%M'),
+ 'update_password_url': reverse('users:user-password-update', external=True),
+ 'forget_password_url': reverse('authentication:forgot-password', external=True),
+ 'email': user.email,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+
+class UserExpirationReminderMsg(BaseUserMessage):
+ def get_text_msg(self) -> dict:
+ subject = _('Expiration notice')
+ message = _("""
+Hello %(name)s:
+
+Your account will expire in %(date_expired)s,
+
+In order not to affect your normal work, please contact the administrator for confirmation.
+
+ """) % {
+ 'name': self.user.name,
+ 'date_expired': datetime.fromtimestamp(datetime.timestamp(
+ self.user.date_expired)).strftime('%Y-%m-%d %H:%M'),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+ def get_html_msg(self) -> dict:
+ subject = _('Expiration notice')
+ message = _("""
+ Hello %(name)s:
+
+ Your account will expire in %(date_expired)s,
+
+ In order not to affect your normal work, please contact the administrator for confirmation.
+
+ """) % {
+ 'name': self.user.name,
+ 'date_expired': datetime.fromtimestamp(datetime.timestamp(
+ self.user.date_expired)).strftime('%Y-%m-%d %H:%M'),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+
+class ResetSSHKeyMsg(BaseUserMessage):
+ def get_text_msg(self) -> dict:
+ subject = _('SSH Key Reset')
+ message = _("""
+Hello %(name)s:
+
+Your ssh public key has been reset by site administrator.
+Please login and reset your ssh public key.
+
+Login direct 👇
+%(login_url)s
+
+ """) % {
+ 'name': self.user.name,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+ def get_html_msg(self) -> dict:
+ subject = _('SSH Key Reset')
+ message = _("""
+ Hello %(name)s:
+
+ Your ssh public key has been reset by site administrator.
+ Please login and reset your ssh public key.
+
+ Login direct
+
+
+ """) % {
+ 'name': self.user.name,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+
+class ResetMFAMsg(BaseUserMessage):
+ def get_text_msg(self) -> dict:
+ subject = _('MFA Reset')
+ message = _("""
+Hello %(name)s:
+
+Your MFA has been reset by site administrator.
+Please login and reset your MFA.
+
+Login direct 👇
+%(login_url)s
+
+ """) % {
+ 'name': self.user.name,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
+
+ def get_html_msg(self) -> dict:
+ subject = _('MFA Reset')
+ message = _("""
+ Hello %(name)s:
+
+ Your MFA has been reset by site administrator.
+ Please login and reset your MFA.
+
+ Login direct
+
+
+ """) % {
+ 'name': self.user.name,
+ 'login_url': reverse('authentication:login', external=True),
+ }
+ return {
+ 'subject': subject,
+ 'message': message
+ }
diff --git a/apps/users/tasks.py b/apps/users/tasks.py
index dfe67d586..58ce4e3ed 100644
--- a/apps/users/tasks.py
+++ b/apps/users/tasks.py
@@ -5,15 +5,14 @@ import sys
from celery import shared_task
from django.conf import settings
+from users.notifications import PasswordExpirationReminderMsg
from ops.celery.utils import (
create_or_update_celery_periodic_tasks, disable_celery_periodic_task
)
from ops.celery.decorator import after_app_ready_start
from common.utils import get_logger
from .models import User
-from .utils import (
- send_password_expiration_reminder_mail, send_user_expiration_reminder_mail
-)
+from users.notifications import UserExpirationReminderMsg
from settings.utils import LDAPServerUtil, LDAPImportUtil
@@ -30,7 +29,8 @@ def check_password_expired():
continue
msg = "The user {} password expires in {} days"
logger.info(msg.format(user, user.password_expired_remain_days))
- send_password_expiration_reminder_mail(user)
+
+ PasswordExpirationReminderMsg(user).publish_async()
@shared_task
@@ -57,7 +57,8 @@ def check_user_expired():
continue
msg = "The user {} will expires in {} days"
logger.info(msg.format(user, user.expired_remain_days))
- send_user_expiration_reminder_mail(user)
+
+ UserExpirationReminderMsg(user).publish_async()
@shared_task
diff --git a/apps/users/utils.py b/apps/users/utils.py
index 8c55a99e5..b89dacdb2 100644
--- a/apps/users/utils.py
+++ b/apps/users/utils.py
@@ -10,7 +10,6 @@ import time
from django.conf import settings
from django.utils.translation import ugettext as _
from django.core.cache import cache
-from datetime import datetime
from common.tasks import send_mail_async
from common.utils import reverse, get_object_or_none, get_request_ip_or_data, get_request_user_agent
@@ -79,184 +78,6 @@ def send_user_created_mail(user):
send_mail_async.delay(subject, message, recipient_list, html_message=message)
-def send_reset_password_mail(user):
- subject = _('Reset password')
- recipient_list = [user.email]
- message = _("""
- Hello %(name)s:
-
- Please click the link below to reset your password, if not your request, concern your account security
-
- Click here reset password
-
- This link is valid for 1 hour. After it expires, request new one
-
-
- ---
-
-
- Login direct
-
-
- """) % {
- 'name': user.name,
- 'rest_password_url': reverse('authentication:reset-password', external=True),
- 'rest_password_token': user.generate_reset_token(),
- 'forget_password_url': reverse('authentication:forgot-password', external=True),
- 'email': user.email,
- 'login_url': reverse('authentication:login', external=True),
- }
- if settings.DEBUG:
- logger.debug(message)
-
- send_mail_async.delay(subject, message, recipient_list, html_message=message)
-
-
-def send_reset_password_success_mail(request, user):
- subject = _('Reset password success')
- recipient_list = [user.email]
- message = _("""
-
- Hi %(name)s:
-
-
-
-
- Your JumpServer password has just been successfully updated.
-
-
-
- If the password update was not initiated by you, your account may have security issues.
- It is recommended that you log on to the JumpServer immediately and change your password.
-
-
-
- If you have any questions, you can contact the administrator.
-
-
- ---
-
-
- IP Address: %(ip_address)s
-
-
- Browser: %(browser)s
-
-
- """) % {
- 'name': user.name,
- 'ip_address': get_request_ip_or_data(request),
- 'browser': get_request_user_agent(request),
- }
- if settings.DEBUG:
- logger.debug(message)
-
- send_mail_async.delay(subject, message, recipient_list, html_message=message)
-
-
-def send_password_expiration_reminder_mail(user):
- subject = _('Security notice')
- recipient_list = [user.email]
- message = _("""
- Hello %(name)s:
-
- Your password will expire in %(date_password_expired)s,
-
- For your account security, please click on the link below to update your password in time
-
- Click here update password
-
- If your password has expired, please click
- Password expired
- to apply for a password reset email.
-
-
- ---
-
-
- Login direct
-
-
- """) % {
- 'name': user.name,
- 'date_password_expired': datetime.fromtimestamp(datetime.timestamp(
- user.date_password_expired)).strftime('%Y-%m-%d %H:%M'),
- 'update_password_url': reverse('users:user-password-update', external=True),
- 'forget_password_url': reverse('authentication:forgot-password', external=True),
- 'email': user.email,
- 'login_url': reverse('authentication:login', external=True),
- }
- if settings.DEBUG:
- logger.debug(message)
-
- send_mail_async.delay(subject, message, recipient_list, html_message=message)
-
-
-def send_user_expiration_reminder_mail(user):
- subject = _('Expiration notice')
- recipient_list = [user.email]
- message = _("""
- Hello %(name)s:
-
- Your account will expire in %(date_expired)s,
-
- In order not to affect your normal work, please contact the administrator for confirmation.
-
- """) % {
- 'name': user.name,
- 'date_expired': datetime.fromtimestamp(datetime.timestamp(
- user.date_expired)).strftime('%Y-%m-%d %H:%M'),
- }
- if settings.DEBUG:
- logger.debug(message)
-
- send_mail_async.delay(subject, message, recipient_list, html_message=message)
-
-
-def send_reset_ssh_key_mail(user):
- subject = _('SSH Key Reset')
- recipient_list = [user.email]
- message = _("""
- Hello %(name)s:
-
- Your ssh public key has been reset by site administrator.
- Please login and reset your ssh public key.
-
- Login direct
-
-
- """) % {
- 'name': user.name,
- 'login_url': reverse('authentication:login', external=True),
- }
- if settings.DEBUG:
- logger.debug(message)
-
- send_mail_async.delay(subject, message, recipient_list, html_message=message)
-
-
-def send_reset_mfa_mail(user):
- subject = _('MFA Reset')
- recipient_list = [user.email]
- message = _("""
- Hello %(name)s:
-
- Your MFA has been reset by site administrator.
- Please login and reset your MFA.
-
- Login direct
-
-
- """) % {
- 'name': user.name,
- 'login_url': reverse('authentication:login', external=True),
- }
- if settings.DEBUG:
- logger.debug(message)
-
- send_mail_async.delay(subject, message, recipient_list, html_message=message)
-
-
def get_user_or_pre_auth_user(request):
user = request.user
if user.is_authenticated:
diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py
index 793322cc9..c3529c73c 100644
--- a/apps/users/views/profile/reset.py
+++ b/apps/users/views/profile/reset.py
@@ -9,13 +9,13 @@ from django.conf import settings
from django.urls import reverse_lazy
from django.views.generic import FormView
+from users.notifications import ResetPasswordSuccessMsg, ResetPasswordMsg
from common.utils import get_object_or_none, FlashMessageUtil
from common.permissions import IsValidUser
from common.mixins.views import PermissionsMixin
from ...models import User
from ...utils import (
- send_reset_password_mail, get_password_check_rules, check_password_rules,
- send_reset_password_success_mail
+ get_password_check_rules, check_password_rules,
)
from ... import forms
@@ -59,7 +59,8 @@ class UserForgotPasswordView(FormView):
).format(user.get_source_display())
form.add_error('email', error)
return self.form_invalid(form)
- send_reset_password_mail(user)
+
+ ResetPasswordMsg(user).publish_async()
url = self.get_redirect_message_url()
return redirect(url)
@@ -115,7 +116,8 @@ class UserResetPasswordView(FormView):
user.reset_password(password)
User.expired_reset_password_token(token)
- send_reset_password_success_mail(self.request, user)
+
+ ResetPasswordSuccessMsg(user, self.request).publish_async()
url = self.get_redirect_url()
return redirect(url)
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index c809a7ad4..aaa1b662a 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -77,7 +77,7 @@ aliyun-python-sdk-core-v3==2.9.1
aliyun-python-sdk-ecs==4.10.1
rest_condition==1.0.3
python-ldap==3.3.1
-tencentcloud-sdk-python==3.0.40
+tencentcloud-sdk-python==3.0.477
django-radius==1.4.0
ipip-ipdb==1.2.1
django-redis-sessions==0.6.1
@@ -118,3 +118,4 @@ google-cloud-compute==0.5.0
PyMySQL==1.0.2
cx-Oracle==8.2.1
psycopg2-binary==2.9.1
+alibabacloud_dysmsapi20170525==2.0.2