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 %}
{% csrf_token %} - {% if 'otp_code' in form.errors %} -

{{ form.otp_code.errors.as_text }}

+ {% if 'code' in form.errors %} +

{{ form.code.errors.as_text }}

{% endif %}
- + {% for method in methods %} + + {% endfor %}
- + - {% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %} + {% trans 'Please enter the verification code' %}
+
{% trans "Can't provide security? Please contact the administrator!" %}
+ {% 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