feat: 添加短信服务和用户消息通知

pull/6792/head
xinwen 2021-08-24 14:20:54 +08:00
parent d49d1e1414
commit b1fceca8a6
57 changed files with 1442 additions and 296 deletions

View File

@ -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)

View File

@ -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})

View File

@ -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')
}
}

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -9,24 +9,60 @@
{% block content %}
<form class="m-t" role="form" method="post" action="">
{% csrf_token %}
{% if 'otp_code' in form.errors %}
<p class="red-fonts">{{ form.otp_code.errors.as_text }}</p>
{% if 'code' in form.errors %}
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
{% endif %}
<div class="form-group">
<select class="form-control">
<option value="otp" selected>{% trans 'One-time password' %}</option>
<select id="verify-method-select" name="mfa_type" class="form-control" onchange="select_change(this.value)">
{% for method in methods %}
<option value="{{ method.name }}" {% if method.selected %} selected {% endif %} {% if not method.enable %} disabled {% endif %}>{{ method.label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<input type="text" class="form-control" name="otp_code" placeholder="" required="" autofocus="autofocus">
<input type="text" class="form-control" name="code" placeholder="" required="" autofocus="autofocus">
<span class="help-block">
{% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %}
{% trans 'Please enter the verification code' %}
</span>
</div>
<button id='send-sms-verify-code' class="btn btn-primary full-width m-b" onclick="sendSMSVerifyCode()" style="display: none">{% trans 'Send verification code' %}</button>
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
<div>
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
</div>
</form>
<script>
var methodSelect = document.getElementById('verify-method-select');
if (methodSelect.value !== null) {
select_change(methodSelect.value);
}
function select_change(type){
var currentBtn = document.getElementById('send-sms-verify-code');
if (type == "sms") {
currentBtn.style.display = "block";
}
else {
currentBtn.style.display = "none";
}
}
function sendSMSVerifyCode(){
var url = "{% url 'api-auth:sms-verify-code-send' %}";
requestApi({
url: url,
method: "POST",
success: function (data) {
alert('验证码已发送');
},
error: function (text, data) {
alert(data.detail)
},
flash_message: false
})
}
</script>
{% endblock %}

View File

@ -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/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')

View File

@ -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__)

View File

@ -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

View File

@ -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

View File

@ -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:
# 只处理可预知的错误

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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',

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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),
),
]

View File

@ -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)
]

View File

@ -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):

View File

@ -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)

View File

@ -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',)

View File

@ -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)

View File

@ -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,

View File

@ -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 = [

View File

@ -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):

View File

@ -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 *

View File

@ -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})

View File

@ -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):

22
apps/settings/api/sms.py Normal file
View File

@ -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)

View File

@ -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})

View File

@ -7,3 +7,4 @@ from .feishu import *
from .wecom import *
from .sso import *
from .base import *
from .sms import *

View File

@ -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": "<Your signature name>",
"template_code": "<Your 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": "<Your signature name>",
"template_code": "<Your template code>"
}
}
'''))

View File

@ -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

View File

@ -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'))

View File

@ -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'),

View File

@ -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]") % {

View File

@ -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()

View File

@ -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"})

View File

@ -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')

View File

@ -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

389
apps/users/notifications.py Normal file
View File

@ -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:
<br>
Please click the link below to reset your password, if not your request, concern your account security
<br>
<a href="%(rest_password_url)s?token=%(rest_password_token)s">Click here reset password</a>
<br>
This link is valid for 1 hour. After it expires, <a href="%(forget_password_url)s?email=%(email)s">request new one</a>
<br>
---
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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
<br>
<br>
Browser: %(browser)s
<br>
""") % {
'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:
<br>
<br>
Your JumpServer password has just been successfully updated.
<br>
<br>
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.
<br>
<br>
If you have any questions, you can contact the administrator.
<br>
<br>
---
<br>
<br>
IP Address: %(ip_address)s
<br>
<br>
Browser: %(browser)s
<br>
""") % {
'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:
<br>
Your password will expire in %(date_password_expired)s,
<br>
For your account security, please click on the link below to update your password in time
<br>
<a href="%(update_password_url)s">Click here update password</a>
<br>
If your password has expired, please click
<a href="%(forget_password_url)s?email=%(email)s">Password expired</a>
to apply for a password reset email.
<br>
---
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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:
<br>
Your account will expire in %(date_expired)s,
<br>
In order not to affect your normal work, please contact the administrator for confirmation.
<br>
""") % {
'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:
<br>
Your ssh public key has been reset by site administrator.
Please login and reset your ssh public key.
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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:
<br>
Your MFA has been reset by site administrator.
Please login and reset your MFA.
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'name': self.user.name,
'login_url': reverse('authentication:login', external=True),
}
return {
'subject': subject,
'message': message
}

View File

@ -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

View File

@ -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:
<br>
Please click the link below to reset your password, if not your request, concern your account security
<br>
<a href="%(rest_password_url)s?token=%(rest_password_token)s">Click here reset password</a>
<br>
This link is valid for 1 hour. After it expires, <a href="%(forget_password_url)s?email=%(email)s">request new one</a>
<br>
---
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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:
<br>
<br>
Your JumpServer password has just been successfully updated.
<br>
<br>
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.
<br>
<br>
If you have any questions, you can contact the administrator.
<br>
<br>
---
<br>
<br>
IP Address: %(ip_address)s
<br>
<br>
Browser: %(browser)s
<br>
""") % {
'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:
<br>
Your password will expire in %(date_password_expired)s,
<br>
For your account security, please click on the link below to update your password in time
<br>
<a href="%(update_password_url)s">Click here update password</a>
<br>
If your password has expired, please click
<a href="%(forget_password_url)s?email=%(email)s">Password expired</a>
to apply for a password reset email.
<br>
---
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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:
<br>
Your account will expire in %(date_expired)s,
<br>
In order not to affect your normal work, please contact the administrator for confirmation.
<br>
""") % {
'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:
<br>
Your ssh public key has been reset by site administrator.
Please login and reset your ssh public key.
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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:
<br>
Your MFA has been reset by site administrator.
Please login and reset your MFA.
<br>
<a href="%(login_url)s">Login direct</a>
<br>
""") % {
'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:

View File

@ -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)

View File

@ -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