mirror of https://github.com/jumpserver/jumpserver
feat: 添加短信服务和用户消息通知
parent
d49d1e1414
commit
b1fceca8a6
|
@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||||
ticket = self.get_ticket()
|
ticket = self.get_ticket()
|
||||||
if ticket:
|
if ticket:
|
||||||
request.session.pop('auth_ticket_id', '')
|
request.session.pop('auth_ticket_id', '')
|
||||||
ticket.close(processor=request.user)
|
ticket.close(processor=self.get_user_from_session())
|
||||||
return Response('', status=200)
|
return Response('', status=200)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
|
import builtins
|
||||||
import time
|
import time
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.response import Response
|
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 ..serializers import OtpVerifySerializer
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
from .. import errors
|
from .. import errors
|
||||||
from ..mixins import AuthMixin
|
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):
|
class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||||
|
@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||||
try:
|
try:
|
||||||
user = self.get_user_from_session()
|
user = self.get_user_from_session()
|
||||||
code = serializer.validated_data.get('code')
|
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:
|
if not valid:
|
||||||
self.request.session['auth_mfa'] = ''
|
self.request.session['auth_mfa'] = ''
|
||||||
raise errors.MFAFailedError(
|
raise errors.MFAFailedError(
|
||||||
|
@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView):
|
||||||
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||||
self.permission_classes = [NeedMFAVerify]
|
self.permission_classes = [NeedMFAVerify]
|
||||||
return super().get_permissions()
|
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})
|
||||||
|
|
|
@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from authentication import sms_verify_code
|
||||||
from common.exceptions import JMSException
|
from common.exceptions import JMSException
|
||||||
from .signals import post_auth_failed
|
from .signals import post_auth_failed
|
||||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||||
|
from users.models import MFAType
|
||||||
|
|
||||||
reason_password_failed = 'password_failed'
|
reason_password_failed = 'password_failed'
|
||||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||||
|
@ -58,8 +60,18 @@ block_mfa_msg = _(
|
||||||
"The account has been locked "
|
"The account has been locked "
|
||||||
"(please contact admin to unlock it or try again after {} minutes)"
|
"(please contact admin to unlock it or try again after {} minutes)"
|
||||||
)
|
)
|
||||||
mfa_failed_msg = _(
|
otp_failed_msg = _(
|
||||||
"MFA code invalid, or ntp sync server time, "
|
"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 "
|
"You can also try {times_try} times "
|
||||||
"(The account will be temporarily locked for {block_time} minutes)"
|
"(The account will be temporarily locked for {block_time} minutes)"
|
||||||
)
|
)
|
||||||
|
@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||||
error = reason_mfa_failed
|
error = reason_mfa_failed
|
||||||
msg: str
|
msg: str
|
||||||
|
|
||||||
def __init__(self, username, request, ip):
|
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
|
||||||
util = MFABlockUtils(username, ip)
|
util = MFABlockUtils(username, ip)
|
||||||
util.incr_failed_count()
|
util.incr_failed_count()
|
||||||
|
|
||||||
|
@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||||
|
|
||||||
if times_remainder:
|
if times_remainder:
|
||||||
self.msg = mfa_failed_msg.format(
|
if mfa_type == MFAType.OTP:
|
||||||
|
self.msg = otp_failed_msg.format(
|
||||||
times_try=times_remainder, block_time=block_time
|
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:
|
else:
|
||||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||||
super().__init__(username=username, request=request)
|
super().__init__(username=username, request=request)
|
||||||
|
@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError):
|
||||||
msg = mfa_required_msg
|
msg = mfa_required_msg
|
||||||
error = 'mfa_required'
|
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):
|
def as_data(self):
|
||||||
return {
|
return {
|
||||||
'error': self.error,
|
'error': self.error,
|
||||||
'msg': self.msg,
|
'msg': self.msg,
|
||||||
'data': {
|
'data': {
|
||||||
'choices': ['code'],
|
'choices': self.choices,
|
||||||
'url': reverse('api-auth:mfa-challenge')
|
'url': reverse('api-auth:mfa-challenge')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
|
||||||
|
|
||||||
|
|
||||||
class UserCheckOtpCodeForm(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):
|
class CustomCaptchaTextInput(CaptchaTextInput):
|
||||||
|
|
|
@ -17,7 +17,7 @@ from django.shortcuts import reverse, redirect
|
||||||
from django.views.generic.edit import FormView
|
from django.views.generic.edit import FormView
|
||||||
|
|
||||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
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 users.utils import LoginBlockUtil, MFABlockUtils
|
||||||
from . import errors
|
from . import errors
|
||||||
from .utils import rsa_decrypt, gen_key_pair
|
from .utils import rsa_decrypt, gen_key_pair
|
||||||
|
@ -351,13 +351,13 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
||||||
unset, url = user.mfa_enabled_but_not_set()
|
unset, url = user.mfa_enabled_but_not_set()
|
||||||
if unset:
|
if unset:
|
||||||
raise errors.MFAUnsetError(user, self.request, url)
|
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'] = 1
|
||||||
self.request.session['auth_mfa_time'] = time.time()
|
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_required'] = ''
|
||||||
|
self.request.session['auth_mfa_type'] = mfa_type
|
||||||
|
|
||||||
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
||||||
if MFABlockUtils(username, ip).is_block():
|
if MFABlockUtils(username, ip).is_block():
|
||||||
|
@ -368,11 +368,11 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
||||||
else:
|
else:
|
||||||
return exception
|
return exception
|
||||||
|
|
||||||
def check_user_mfa(self, code):
|
def check_user_mfa(self, code, mfa_type=MFAType.OTP):
|
||||||
user = self.get_user_from_session()
|
user = self.get_user_from_session()
|
||||||
ip = self.get_request_ip()
|
ip = self.get_request_ip()
|
||||||
self.check_mfa_is_block(user.username, 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:
|
if ok:
|
||||||
self.mark_mfa_ok()
|
self.mark_mfa_ok()
|
||||||
return
|
return
|
||||||
|
@ -380,7 +380,7 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
||||||
raise errors.MFAFailedError(
|
raise errors.MFAFailedError(
|
||||||
username=user.username,
|
username=user.username,
|
||||||
request=self.request,
|
request=self.request,
|
||||||
ip=ip
|
ip=ip, mfa_type=mfa_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_ticket(self):
|
def get_ticket(self):
|
||||||
|
|
|
@ -17,7 +17,7 @@ __all__ = [
|
||||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
||||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
||||||
'PasswordVerifySerializer',
|
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer):
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class MFASelectTypeSerializer(serializers.Serializer):
|
||||||
|
type = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
class MFAChallengeSerializer(serializers.Serializer):
|
class MFAChallengeSerializer(serializers.Serializer):
|
||||||
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||||
code = serializers.CharField(write_only=True)
|
code = serializers.CharField(write_only=True)
|
||||||
|
|
|
@ -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)
|
|
@ -9,24 +9,60 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form class="m-t" role="form" method="post" action="">
|
<form class="m-t" role="form" method="post" action="">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if 'otp_code' in form.errors %}
|
{% if 'code' in form.errors %}
|
||||||
<p class="red-fonts">{{ form.otp_code.errors.as_text }}</p>
|
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select class="form-control">
|
<select id="verify-method-select" name="mfa_type" class="form-control" onchange="select_change(this.value)">
|
||||||
<option value="otp" selected>{% trans 'One-time password' %}</option>
|
{% for method in methods %}
|
||||||
|
<option value="{{ method.name }}" {% if method.selected %} selected {% endif %} {% if not method.enable %} disabled {% endif %}>{{ method.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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">
|
<span class="help-block">
|
||||||
{% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %}
|
{% trans 'Please enter the verification code' %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
@ -27,7 +27,9 @@ urlpatterns = [
|
||||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
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('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('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-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')
|
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
|
||||||
|
|
|
@ -4,7 +4,6 @@ import base64
|
||||||
from Cryptodome.PublicKey import RSA
|
from Cryptodome.PublicKey import RSA
|
||||||
from Cryptodome.Cipher import PKCS1_v1_5
|
from Cryptodome.Cipher import PKCS1_v1_5
|
||||||
from Cryptodome import Random
|
from Cryptodome import Random
|
||||||
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
from django.views.generic.edit import FormView
|
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 .. import forms, errors, mixins
|
||||||
from .utils import redirect_to_guard_view
|
from .utils import redirect_to_guard_view
|
||||||
|
|
||||||
|
@ -18,12 +20,14 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
|
||||||
redirect_field_name = 'next'
|
redirect_field_name = 'next'
|
||||||
|
|
||||||
def form_valid(self, form):
|
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:
|
try:
|
||||||
self.check_user_mfa(otp_code)
|
self.check_user_mfa(otp_code, mfa_type)
|
||||||
return redirect_to_guard_view()
|
return redirect_to_guard_view()
|
||||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
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)
|
return super().form_invalid(form)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
|
@ -31,3 +35,28 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
|
||||||
traceback.print_exception()
|
traceback.print_exception()
|
||||||
return redirect_to_guard_view()
|
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
|
||||||
|
|
|
@ -2,9 +2,12 @@ import time
|
||||||
import hmac
|
import hmac
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from common.utils import get_logger
|
||||||
from common.message.backends.utils import digest, as_request
|
from common.message.backends.utils import digest, as_request
|
||||||
from common.message.backends.mixin import BaseRequest
|
from common.message.backends.mixin import BaseRequest
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
def sign(secret, data):
|
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)
|
data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
|
@ -106,6 +106,7 @@ class FeiShu(RequestMixin):
|
||||||
body['receive_id'] = user_id
|
body['receive_id'] = user_id
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}')
|
||||||
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
|
self._requests.post(URL.SEND_MESSAGE, params=params, json=body)
|
||||||
except APIException as e:
|
except APIException as e:
|
||||||
# 只处理可预知的错误
|
# 只处理可预知的错误
|
||||||
|
|
|
@ -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))
|
|
@ -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
|
|
@ -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
|
|
@ -115,6 +115,7 @@ class WeCom(RequestMixin):
|
||||||
},
|
},
|
||||||
**extra_params
|
**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)
|
data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False)
|
||||||
|
|
||||||
errcode = data['errcode']
|
errcode = data['errcode']
|
||||||
|
|
|
@ -191,3 +191,11 @@ class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission):
|
||||||
return False
|
return False
|
||||||
query_user = current_org.get_members().filter(id=query_user_id).first()
|
query_user = current_org.get_members().filter(id=query_user_id).first()
|
||||||
return bool(query_user)
|
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
|
||||||
|
|
|
@ -243,6 +243,19 @@ class Config(dict):
|
||||||
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS
|
'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS
|
||||||
'LOGIN_REDIRECT_MSG_ENABLED': True,
|
'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_VALID_WINDOW': 2,
|
||||||
'OTP_ISSUER_NAME': 'JumpServer',
|
'OTP_ISSUER_NAME': 'JumpServer',
|
||||||
'EMAIL_SUFFIX': 'example.com',
|
'EMAIL_SUFFIX': 'example.com',
|
||||||
|
|
|
@ -122,6 +122,22 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU
|
||||||
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
|
FEISHU_APP_ID = CONFIG.FEISHU_APP_ID
|
||||||
FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET
|
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
|
# Other setting
|
||||||
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION
|
||||||
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
|
LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
from django.http import Http404
|
from rest_framework.mixins import ListModelMixin, UpdateModelMixin, RetrieveModelMixin
|
||||||
from rest_framework.mixins import ListModelMixin, UpdateModelMixin
|
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
from common.drf.api import JMSGenericViewSet
|
from common.drf.api import JMSGenericViewSet
|
||||||
|
from common.permissions import IsObjectOwner, IsSuperUser, OnlySuperUserCanList
|
||||||
from notifications.notifications import system_msgs
|
from notifications.notifications import system_msgs
|
||||||
from notifications.models import SystemMsgSubscription
|
from notifications.models import SystemMsgSubscription, UserMsgSubscription
|
||||||
from notifications.backends import BACKEND
|
from notifications.backends import BACKEND
|
||||||
from notifications.serializers import (
|
from notifications.serializers import (
|
||||||
SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer
|
SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer,
|
||||||
|
UserMsgSubscriptionSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet')
|
__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet', 'UserMsgSubscriptionViewSet')
|
||||||
|
|
||||||
|
|
||||||
class BackendListView(APIView):
|
class BackendListView(APIView):
|
||||||
|
@ -70,3 +70,13 @@ class SystemMsgSubscriptionViewSet(ListModelMixin,
|
||||||
|
|
||||||
serializer = self.get_serializer(data, many=True)
|
serializer = self.get_serializer(data, many=True)
|
||||||
return Response(data=serializer.data)
|
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)
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
|
import importlib
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from .dingtalk import DingTalk
|
client_name_mapper = {}
|
||||||
from .email import Email
|
|
||||||
from .site_msg import SiteMessage
|
|
||||||
from .wecom import WeCom
|
|
||||||
from .feishu import FeiShu
|
|
||||||
|
|
||||||
|
|
||||||
class BACKEND(models.TextChoices):
|
class BACKEND(models.TextChoices):
|
||||||
|
@ -14,17 +12,11 @@ class BACKEND(models.TextChoices):
|
||||||
DINGTALK = 'dingtalk', _('DingTalk')
|
DINGTALK = 'dingtalk', _('DingTalk')
|
||||||
SITE_MSG = 'site_msg', _('Site message')
|
SITE_MSG = 'site_msg', _('Site message')
|
||||||
FEISHU = 'feishu', _('FeiShu')
|
FEISHU = 'feishu', _('FeiShu')
|
||||||
|
SMS = 'sms', _('SMS')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def client(self):
|
def client(self):
|
||||||
client = {
|
return client_name_mapper[self]
|
||||||
self.EMAIL: Email,
|
|
||||||
self.WECOM: WeCom,
|
|
||||||
self.DINGTALK: DingTalk,
|
|
||||||
self.SITE_MSG: SiteMessage,
|
|
||||||
self.FEISHU: FeiShu,
|
|
||||||
}[self]
|
|
||||||
return client
|
|
||||||
|
|
||||||
def get_account(self, user):
|
def get_account(self, user):
|
||||||
return self.client.get_account(user)
|
return self.client.get_account(user)
|
||||||
|
@ -37,3 +29,8 @@ class BACKEND(models.TextChoices):
|
||||||
def filter_enable_backends(cls, backends):
|
def filter_enable_backends(cls, backends):
|
||||||
enable_backends = [b for b in backends if cls(b).is_enable]
|
enable_backends = [b for b in backends if cls(b).is_enable]
|
||||||
return enable_backends
|
return enable_backends
|
||||||
|
|
||||||
|
|
||||||
|
for b in BACKEND:
|
||||||
|
m = importlib.import_module(f'.{b}', __package__)
|
||||||
|
client_name_mapper[b] = m.backend
|
||||||
|
|
|
@ -14,6 +14,9 @@ class DingTalk(BackendBase):
|
||||||
agentid=settings.DINGTALK_AGENTID
|
agentid=settings.DINGTALK_AGENTID
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_msg(self, users, msg):
|
def send_msg(self, users, message, subject=None):
|
||||||
accounts, __, __ = self.get_accounts(users)
|
accounts, __, __ = self.get_accounts(users)
|
||||||
return self.dingtalk.send_text(accounts, msg)
|
return self.dingtalk.send_text(accounts, message)
|
||||||
|
|
||||||
|
|
||||||
|
backend = DingTalk
|
||||||
|
|
|
@ -8,7 +8,10 @@ class Email(BackendBase):
|
||||||
account_field = 'email'
|
account_field = 'email'
|
||||||
is_enable_field_in_settings = 'EMAIL_HOST_USER'
|
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
|
from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER
|
||||||
accounts, __, __ = self.get_accounts(users)
|
accounts, __, __ = self.get_accounts(users)
|
||||||
send_mail(subject, message, from_email, accounts, html_message=message)
|
send_mail(subject, message, from_email, accounts, html_message=message)
|
||||||
|
|
||||||
|
|
||||||
|
backend = Email
|
||||||
|
|
|
@ -14,6 +14,9 @@ class FeiShu(BackendBase):
|
||||||
app_secret=settings.FEISHU_APP_SECRET
|
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)
|
accounts, __, __ = self.get_accounts(users)
|
||||||
return self.client.send_text(accounts, msg)
|
return self.client.send_text(accounts, message)
|
||||||
|
|
||||||
|
|
||||||
|
backend = FeiShu
|
||||||
|
|
|
@ -5,10 +5,13 @@ from .base import BackendBase
|
||||||
class SiteMessage(BackendBase):
|
class SiteMessage(BackendBase):
|
||||||
account_field = 'id'
|
account_field = 'id'
|
||||||
|
|
||||||
def send_msg(self, users, subject, message):
|
def send_msg(self, users, message, subject):
|
||||||
accounts, __, __ = self.get_accounts(users)
|
accounts, __, __ = self.get_accounts(users)
|
||||||
Client.send_msg(subject, message, user_ids=accounts)
|
Client.send_msg(subject, message, user_ids=accounts)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_enable(cls):
|
def is_enable(cls):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
backend = SiteMessage
|
||||||
|
|
|
@ -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
|
|
@ -15,6 +15,9 @@ class WeCom(BackendBase):
|
||||||
agentid=settings.WECOM_AGENTID
|
agentid=settings.WECOM_AGENTID
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_msg(self, users, msg):
|
def send_msg(self, users, message, subject=None):
|
||||||
accounts, __, __ = self.get_accounts(users)
|
accounts, __, __ = self.get_accounts(users)
|
||||||
return self.wecom.send_text(accounts, msg)
|
return self.wecom.send_text(accounts, message)
|
||||||
|
|
||||||
|
|
||||||
|
backend = WeCom
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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)
|
||||||
|
]
|
|
@ -6,12 +6,11 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription')
|
||||||
|
|
||||||
|
|
||||||
class UserMsgSubscription(JMSModel):
|
class UserMsgSubscription(JMSModel):
|
||||||
message_type = models.CharField(max_length=128)
|
user = models.ForeignKey('users.User', unique=True, related_name='user_msg_subscriptions', on_delete=models.CASCADE)
|
||||||
user = models.ForeignKey('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE)
|
|
||||||
receive_backends = models.JSONField(default=list)
|
receive_backends = models.JSONField(default=list)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.message_type}'
|
return f'{self.user} subscription: {self.receive_backends}'
|
||||||
|
|
||||||
|
|
||||||
class SystemMsgSubscription(JMSModel):
|
class SystemMsgSubscription(JMSModel):
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
import traceback
|
import traceback
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.db.utils import ProgrammingError
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
|
from common.utils import lazyproperty
|
||||||
|
from users.models import User
|
||||||
from notifications.backends import BACKEND
|
from notifications.backends import BACKEND
|
||||||
from .models import SystemMsgSubscription
|
from .models import SystemMsgSubscription, UserMsgSubscription
|
||||||
|
|
||||||
__all__ = ('SystemMessage', 'UserMessage')
|
__all__ = ('SystemMessage', 'UserMessage')
|
||||||
|
|
||||||
|
@ -69,37 +71,49 @@ class Message(metaclass=MessageType):
|
||||||
for backend in backends:
|
for backend in backends:
|
||||||
try:
|
try:
|
||||||
backend = BACKEND(backend)
|
backend = BACKEND(backend)
|
||||||
|
if not backend.is_enable:
|
||||||
|
continue
|
||||||
|
|
||||||
get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg)
|
get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg)
|
||||||
|
|
||||||
|
try:
|
||||||
msg = get_msg_method()
|
msg = get_msg_method()
|
||||||
|
except NotImplementedError:
|
||||||
|
continue
|
||||||
|
|
||||||
client = backend.client()
|
client = backend.client()
|
||||||
|
|
||||||
if isinstance(msg, dict):
|
|
||||||
client.send_msg(users, **msg)
|
client.send_msg(users, **msg)
|
||||||
else:
|
|
||||||
client.send_msg(users, msg)
|
|
||||||
except:
|
except:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
def get_common_msg(self) -> str:
|
def get_common_msg(self) -> dict:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def get_dingtalk_msg(self) -> str:
|
@lazyproperty
|
||||||
|
def common_msg(self) -> dict:
|
||||||
return self.get_common_msg()
|
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:
|
def get_email_msg(self) -> dict:
|
||||||
msg = self.get_common_msg()
|
return self.common_msg
|
||||||
subject = f'{msg[:80]} ...' if len(msg) >= 80 else msg
|
|
||||||
return {
|
|
||||||
'subject': subject,
|
|
||||||
'message': msg
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_site_msg_msg(self) -> dict:
|
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):
|
class SystemMessage(Message):
|
||||||
|
@ -125,4 +139,16 @@ class SystemMessage(Message):
|
||||||
|
|
||||||
|
|
||||||
class UserMessage(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)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from common.drf.serializers import BulkModelSerializer
|
from common.drf.serializers import BulkModelSerializer
|
||||||
from notifications.models import SystemMsgSubscription
|
from notifications.models import SystemMsgSubscription, UserMsgSubscription
|
||||||
|
|
||||||
|
|
||||||
class SystemMsgSubscriptionSerializer(BulkModelSerializer):
|
class SystemMsgSubscriptionSerializer(BulkModelSerializer):
|
||||||
|
@ -27,3 +27,11 @@ class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer):
|
||||||
category = serializers.CharField()
|
category = serializers.CharField()
|
||||||
category_label = serializers.CharField()
|
category_label = serializers.CharField()
|
||||||
children = SystemMsgSubscriptionSerializer(many=True)
|
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',)
|
||||||
|
|
|
@ -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_save
|
||||||
from django.db.models.signals import post_migrate
|
from django.db.models.signals import post_migrate
|
||||||
from django.dispatch import receiver
|
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 django.apps import AppConfig
|
||||||
|
|
||||||
|
from notifications.backends import BACKEND
|
||||||
|
from users.models import User
|
||||||
from common.utils.connection import RedisPubSub
|
from common.utils.connection import RedisPubSub
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from common.decorator import on_transaction_commit
|
from common.decorator import on_transaction_commit
|
||||||
from .models import SiteMessage, SystemMsgSubscription
|
from .models import SiteMessage, SystemMsgSubscription, UserMsgSubscription
|
||||||
from .notifications import SystemMessage
|
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}')
|
logger.info(f'Create SystemMsgSubscription: package={app_config.module.__package__} type={message_type}')
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
pass
|
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)
|
||||||
|
|
|
@ -2,9 +2,12 @@ from django.db.models import F
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from common.utils.timezone import now
|
from common.utils.timezone import now
|
||||||
|
from common.utils import get_logger
|
||||||
from users.models import User
|
from users.models import User
|
||||||
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
|
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
|
||||||
|
|
||||||
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
class SiteMessageUtil:
|
class SiteMessageUtil:
|
||||||
|
|
||||||
|
@ -14,6 +17,11 @@ class SiteMessageUtil:
|
||||||
if not any((user_ids, group_ids, is_broadcast)):
|
if not any((user_ids, group_ids, is_broadcast)):
|
||||||
raise ValueError('No recipient is specified')
|
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():
|
with transaction.atomic():
|
||||||
site_msg = SiteMessageModel.objects.create(
|
site_msg = SiteMessageModel.objects.create(
|
||||||
subject=subject, message=message,
|
subject=subject, message=message,
|
||||||
|
|
|
@ -8,6 +8,7 @@ app_name = 'notifications'
|
||||||
|
|
||||||
router = BulkRouter()
|
router = BulkRouter()
|
||||||
router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription')
|
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')
|
router.register('site-message', api.SiteMessageViewSet, 'site-message')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
|
@ -18,7 +18,10 @@ class ServerPerformanceMessage(SystemMessage):
|
||||||
self._msg = msg
|
self._msg = msg
|
||||||
|
|
||||||
def get_common_msg(self):
|
def get_common_msg(self):
|
||||||
return self._msg
|
return {
|
||||||
|
'subject': self._msg[:80],
|
||||||
|
'message': self._msg
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
|
def post_insert_to_db(cls, subscription: SystemMsgSubscription):
|
||||||
|
|
|
@ -5,3 +5,6 @@ from .dingtalk import *
|
||||||
from .feishu import *
|
from .feishu import *
|
||||||
from .public import *
|
from .public import *
|
||||||
from .email import *
|
from .email import *
|
||||||
|
from .alibaba_sms import *
|
||||||
|
from .tencent_sms import *
|
||||||
|
from .sms import *
|
||||||
|
|
|
@ -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})
|
|
@ -34,6 +34,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView):
|
||||||
'sso': serializers.SSOSettingSerializer,
|
'sso': serializers.SSOSettingSerializer,
|
||||||
'clean': serializers.CleaningSerializer,
|
'clean': serializers.CleaningSerializer,
|
||||||
'other': serializers.OtherSettingSerializer,
|
'other': serializers.OtherSettingSerializer,
|
||||||
|
'alibaba': serializers.AlibabaSMSSettingSerializer,
|
||||||
|
'tencent': serializers.TencentSMSSettingSerializer,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
|
|
|
@ -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)
|
|
@ -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})
|
|
@ -7,3 +7,4 @@ from .feishu import *
|
||||||
from .wecom import *
|
from .wecom import *
|
||||||
from .sso import *
|
from .sso import *
|
||||||
from .base import *
|
from .base import *
|
||||||
|
from .sms import *
|
||||||
|
|
|
@ -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>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''))
|
|
@ -6,12 +6,14 @@ from .email import EmailSettingSerializer, EmailContentSettingSerializer
|
||||||
from .auth import (
|
from .auth import (
|
||||||
LDAPSettingSerializer, OIDCSettingSerializer, KeycloakSettingSerializer,
|
LDAPSettingSerializer, OIDCSettingSerializer, KeycloakSettingSerializer,
|
||||||
CASSettingSerializer, RadiusSettingSerializer, FeiShuSettingSerializer,
|
CASSettingSerializer, RadiusSettingSerializer, FeiShuSettingSerializer,
|
||||||
WeComSettingSerializer, DingTalkSettingSerializer
|
WeComSettingSerializer, DingTalkSettingSerializer, AlibabaSMSSettingSerializer,
|
||||||
|
TencentSMSSettingSerializer,
|
||||||
)
|
)
|
||||||
from .terminal import TerminalSettingSerializer
|
from .terminal import TerminalSettingSerializer
|
||||||
from .security import SecuritySettingSerializer
|
from .security import SecuritySettingSerializer
|
||||||
from .cleaning import CleaningSerializer
|
from .cleaning import CleaningSerializer
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'SettingsSerializer',
|
'SettingsSerializer',
|
||||||
]
|
]
|
||||||
|
@ -32,7 +34,9 @@ class SettingsSerializer(
|
||||||
KeycloakSettingSerializer,
|
KeycloakSettingSerializer,
|
||||||
CASSettingSerializer,
|
CASSettingSerializer,
|
||||||
RadiusSettingSerializer,
|
RadiusSettingSerializer,
|
||||||
CleaningSerializer
|
CleaningSerializer,
|
||||||
|
AlibabaSMSSettingSerializer,
|
||||||
|
TencentSMSSettingSerializer,
|
||||||
):
|
):
|
||||||
# encrypt_fields 现在使用 write_only 来判断了
|
# encrypt_fields 现在使用 write_only 来判断了
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -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'))
|
|
@ -16,6 +16,9 @@ urlpatterns = [
|
||||||
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'),
|
path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'),
|
||||||
path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'),
|
path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'),
|
||||||
path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-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('setting/', api.SettingsApi.as_view(), name='settings-setting'),
|
||||||
path('public/', api.PublicSettingApi.as_view(), name='public-setting'),
|
path('public/', api.PublicSettingApi.as_view(), name='public-setting'),
|
||||||
|
|
|
@ -81,7 +81,12 @@ class CommandAlertMessage(CommandAlertMixin, SystemMessage):
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def get_common_msg(self):
|
def get_common_msg(self):
|
||||||
return self._get_message()
|
msg = self._get_message()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'subject': msg[:80],
|
||||||
|
'message': msg
|
||||||
|
}
|
||||||
|
|
||||||
def get_email_msg(self):
|
def get_email_msg(self):
|
||||||
command = self.command
|
command = self.command
|
||||||
|
@ -140,9 +145,6 @@ class CommandExecutionAlert(CommandAlertMixin, SystemMessage):
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def get_common_msg(self):
|
def get_common_msg(self):
|
||||||
return self._get_message()
|
|
||||||
|
|
||||||
def get_email_msg(self):
|
|
||||||
command = self.command
|
command = self.command
|
||||||
|
|
||||||
subject = _("Insecure Web Command Execution Alert: [%(name)s]") % {
|
subject = _("Insecure Web Command Execution Alert: [%(name)s]") % {
|
||||||
|
|
|
@ -4,14 +4,13 @@ import uuid
|
||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from common.permissions import IsOrgAdmin
|
from common.permissions import IsOrgAdmin
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
from users.notifications import ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg
|
||||||
from common.permissions import (
|
from common.permissions import (
|
||||||
IsCurrentUserOrReadOnly
|
IsCurrentUserOrReadOnly
|
||||||
)
|
)
|
||||||
from .. import serializers
|
from .. import serializers
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..utils import send_reset_password_success_mail
|
|
||||||
from .mixins import UserQuerysetMixin
|
from .mixins import UserQuerysetMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -29,11 +28,10 @@ class UserResetPasswordApi(UserQuerysetMixin, generics.UpdateAPIView):
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
# Note: we are not updating the user object here.
|
# Note: we are not updating the user object here.
|
||||||
# We just do the reset-password stuff.
|
# We just do the reset-password stuff.
|
||||||
from ..utils import send_reset_password_mail
|
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
user.password_raw = str(uuid.uuid4())
|
user.password_raw = str(uuid.uuid4())
|
||||||
user.save()
|
user.save()
|
||||||
send_reset_password_mail(user)
|
ResetPasswordMsg(user).publish_async()
|
||||||
|
|
||||||
|
|
||||||
class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView):
|
class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView):
|
||||||
|
@ -41,11 +39,11 @@ class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView):
|
||||||
permission_classes = (IsOrgAdmin,)
|
permission_classes = (IsOrgAdmin,)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
from ..utils import send_reset_ssh_key_mail
|
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
user.public_key = None
|
user.public_key = None
|
||||||
user.save()
|
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):
|
def perform_update(self, serializer):
|
||||||
super().perform_update(serializer)
|
super().perform_update(serializer)
|
||||||
send_reset_password_success_mail(self.request, self.get_object())
|
ResetPasswordSuccessMsg(self.get_object(), self.request).publish_async()
|
||||||
|
|
|
@ -8,6 +8,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework_bulk import BulkModelViewSet
|
from rest_framework_bulk import BulkModelViewSet
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
from users.notifications import ResetMFAMsg
|
||||||
from common.permissions import (
|
from common.permissions import (
|
||||||
IsOrgAdmin, IsOrgAdminOrAppUser,
|
IsOrgAdmin, IsOrgAdminOrAppUser,
|
||||||
CanUpdateDeleteUser, IsSuperUser
|
CanUpdateDeleteUser, IsSuperUser
|
||||||
|
@ -16,7 +17,7 @@ from common.mixins import CommonApiMixin
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from orgs.utils import current_org
|
from orgs.utils import current_org
|
||||||
from orgs.models import ROLE as ORG_ROLE, OrganizationMember
|
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 .. import serializers
|
||||||
from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer
|
from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer
|
||||||
from .mixins import UserQuerysetMixin
|
from .mixins import UserQuerysetMixin
|
||||||
|
@ -209,5 +210,6 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView):
|
||||||
if user.mfa_enabled:
|
if user.mfa_enabled:
|
||||||
user.reset_mfa()
|
user.reset_mfa()
|
||||||
user.save()
|
user.save()
|
||||||
send_reset_mfa_mail(user)
|
|
||||||
|
ResetMFAMsg(user).publish_async()
|
||||||
return Response({"msg": "success"})
|
return Response({"msg": "success"})
|
||||||
|
|
|
@ -8,3 +8,13 @@ class MFANotEnabled(JMSException):
|
||||||
status_code = status.HTTP_403_FORBIDDEN
|
status_code = status.HTTP_403_FORBIDDEN
|
||||||
default_code = 'mfa_not_enabled'
|
default_code = 'mfa_not_enabled'
|
||||||
default_detail = _('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')
|
||||||
|
|
|
@ -20,18 +20,24 @@ from django.shortcuts import reverse
|
||||||
|
|
||||||
from orgs.utils import current_org
|
from orgs.utils import current_org
|
||||||
from orgs.models import OrganizationMember, Organization
|
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.utils import date_expired_default, get_logger, lazyproperty, random_string
|
||||||
from common import fields
|
from common import fields
|
||||||
from common.const import choices
|
from common.const import choices
|
||||||
from common.db.models import TextChoices
|
from common.db.models import TextChoices
|
||||||
from users.exceptions import MFANotEnabled
|
from users.exceptions import MFANotEnabled, PhoneNotSet
|
||||||
from ..signals import post_user_change_password
|
from ..signals import post_user_change_password
|
||||||
|
|
||||||
__all__ = ['User', 'UserPasswordHistory']
|
__all__ = ['User', 'UserPasswordHistory', 'MFAType']
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
class MFAType(TextChoices):
|
||||||
|
OTP = 'otp', _('One-time password')
|
||||||
|
SMS_CODE = 'sms', _('SMS verify code')
|
||||||
|
|
||||||
|
|
||||||
class AuthMixin:
|
class AuthMixin:
|
||||||
date_password_last_updated: datetime.datetime
|
date_password_last_updated: datetime.datetime
|
||||||
is_local: bool
|
is_local: bool
|
||||||
|
@ -514,19 +520,52 @@ class MFAMixin:
|
||||||
from ..utils import check_otp_code
|
from ..utils import check_otp_code
|
||||||
return check_otp_code(self.otp_secret_key, 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:
|
if not self.mfa_enabled:
|
||||||
raise MFANotEnabled
|
raise MFANotEnabled
|
||||||
|
|
||||||
|
if mfa_type == MFAType.OTP:
|
||||||
if settings.OTP_IN_RADIUS:
|
if settings.OTP_IN_RADIUS:
|
||||||
return self.check_radius(code)
|
return self.check_radius(code)
|
||||||
else:
|
else:
|
||||||
return self.check_otp(code)
|
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):
|
def mfa_enabled_but_not_set(self):
|
||||||
if not self.mfa_enabled:
|
if not self.mfa_enabled:
|
||||||
return False, None
|
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 True, reverse('authentication:user-otp-enable-start')
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -5,15 +5,14 @@ import sys
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from users.notifications import PasswordExpirationReminderMsg
|
||||||
from ops.celery.utils import (
|
from ops.celery.utils import (
|
||||||
create_or_update_celery_periodic_tasks, disable_celery_periodic_task
|
create_or_update_celery_periodic_tasks, disable_celery_periodic_task
|
||||||
)
|
)
|
||||||
from ops.celery.decorator import after_app_ready_start
|
from ops.celery.decorator import after_app_ready_start
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
from .models import User
|
from .models import User
|
||||||
from .utils import (
|
from users.notifications import UserExpirationReminderMsg
|
||||||
send_password_expiration_reminder_mail, send_user_expiration_reminder_mail
|
|
||||||
)
|
|
||||||
from settings.utils import LDAPServerUtil, LDAPImportUtil
|
from settings.utils import LDAPServerUtil, LDAPImportUtil
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,7 +29,8 @@ def check_password_expired():
|
||||||
continue
|
continue
|
||||||
msg = "The user {} password expires in {} days"
|
msg = "The user {} password expires in {} days"
|
||||||
logger.info(msg.format(user, user.password_expired_remain_days))
|
logger.info(msg.format(user, user.password_expired_remain_days))
|
||||||
send_password_expiration_reminder_mail(user)
|
|
||||||
|
PasswordExpirationReminderMsg(user).publish_async()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
|
@ -57,7 +57,8 @@ def check_user_expired():
|
||||||
continue
|
continue
|
||||||
msg = "The user {} will expires in {} days"
|
msg = "The user {} will expires in {} days"
|
||||||
logger.info(msg.format(user, user.expired_remain_days))
|
logger.info(msg.format(user, user.expired_remain_days))
|
||||||
send_user_expiration_reminder_mail(user)
|
|
||||||
|
UserExpirationReminderMsg(user).publish_async()
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
|
|
|
@ -10,7 +10,6 @@ import time
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from common.tasks import send_mail_async
|
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
|
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)
|
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):
|
def get_user_or_pre_auth_user(request):
|
||||||
user = request.user
|
user = request.user
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
|
|
|
@ -9,13 +9,13 @@ from django.conf import settings
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.views.generic import FormView
|
from django.views.generic import FormView
|
||||||
|
|
||||||
|
from users.notifications import ResetPasswordSuccessMsg, ResetPasswordMsg
|
||||||
from common.utils import get_object_or_none, FlashMessageUtil
|
from common.utils import get_object_or_none, FlashMessageUtil
|
||||||
from common.permissions import IsValidUser
|
from common.permissions import IsValidUser
|
||||||
from common.mixins.views import PermissionsMixin
|
from common.mixins.views import PermissionsMixin
|
||||||
from ...models import User
|
from ...models import User
|
||||||
from ...utils import (
|
from ...utils import (
|
||||||
send_reset_password_mail, get_password_check_rules, check_password_rules,
|
get_password_check_rules, check_password_rules,
|
||||||
send_reset_password_success_mail
|
|
||||||
)
|
)
|
||||||
from ... import forms
|
from ... import forms
|
||||||
|
|
||||||
|
@ -59,7 +59,8 @@ class UserForgotPasswordView(FormView):
|
||||||
).format(user.get_source_display())
|
).format(user.get_source_display())
|
||||||
form.add_error('email', error)
|
form.add_error('email', error)
|
||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
send_reset_password_mail(user)
|
|
||||||
|
ResetPasswordMsg(user).publish_async()
|
||||||
url = self.get_redirect_message_url()
|
url = self.get_redirect_message_url()
|
||||||
return redirect(url)
|
return redirect(url)
|
||||||
|
|
||||||
|
@ -115,7 +116,8 @@ class UserResetPasswordView(FormView):
|
||||||
|
|
||||||
user.reset_password(password)
|
user.reset_password(password)
|
||||||
User.expired_reset_password_token(token)
|
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()
|
url = self.get_redirect_url()
|
||||||
return redirect(url)
|
return redirect(url)
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,7 @@ aliyun-python-sdk-core-v3==2.9.1
|
||||||
aliyun-python-sdk-ecs==4.10.1
|
aliyun-python-sdk-ecs==4.10.1
|
||||||
rest_condition==1.0.3
|
rest_condition==1.0.3
|
||||||
python-ldap==3.3.1
|
python-ldap==3.3.1
|
||||||
tencentcloud-sdk-python==3.0.40
|
tencentcloud-sdk-python==3.0.477
|
||||||
django-radius==1.4.0
|
django-radius==1.4.0
|
||||||
ipip-ipdb==1.2.1
|
ipip-ipdb==1.2.1
|
||||||
django-redis-sessions==0.6.1
|
django-redis-sessions==0.6.1
|
||||||
|
@ -118,3 +118,4 @@ google-cloud-compute==0.5.0
|
||||||
PyMySQL==1.0.2
|
PyMySQL==1.0.2
|
||||||
cx-Oracle==8.2.1
|
cx-Oracle==8.2.1
|
||||||
psycopg2-binary==2.9.1
|
psycopg2-binary==2.9.1
|
||||||
|
alibabacloud_dysmsapi20170525==2.0.2
|
||||||
|
|
Loading…
Reference in New Issue