mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-09-03 00:15:20 +00:00
feat: 添加短信服务和用户消息通知
This commit is contained in:
@@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
|
||||
ticket = self.get_ticket()
|
||||
if ticket:
|
||||
request.session.pop('auth_ticket_id', '')
|
||||
ticket.close(processor=request.user)
|
||||
ticket.close(processor=self.get_user_from_session())
|
||||
return Response('', status=200)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import builtins
|
||||
import time
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.conf import settings
|
||||
@@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.permissions import IsValidUser, NeedMFAVerify
|
||||
from authentication.sms_verify_code import VerifyCodeUtil
|
||||
from common.exceptions import JMSException
|
||||
from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
|
||||
from users.models.user import MFAType
|
||||
from ..serializers import OtpVerifySerializer
|
||||
from .. import serializers
|
||||
from .. import errors
|
||||
from ..mixins import AuthMixin
|
||||
|
||||
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
|
||||
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
|
||||
|
||||
|
||||
class MFASelectTypeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
serializer_class = serializers.MFASelectTypeSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
mfa_type = serializer.validated_data['type']
|
||||
if mfa_type == MFAType.SMS_CODE:
|
||||
user = self.get_user_from_session()
|
||||
user.send_sms_code()
|
||||
|
||||
|
||||
class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
@@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
|
||||
try:
|
||||
user = self.get_user_from_session()
|
||||
code = serializer.validated_data.get('code')
|
||||
valid = user.check_mfa(code)
|
||||
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
|
||||
|
||||
valid = user.check_mfa(code, mfa_type=mfa_type)
|
||||
if not valid:
|
||||
self.request.session['auth_mfa'] = ''
|
||||
raise errors.MFAFailedError(
|
||||
@@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView):
|
||||
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
self.permission_classes = [NeedMFAVerify]
|
||||
return super().get_permissions()
|
||||
|
||||
|
||||
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
|
||||
permission_classes = (AllowAny,)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = self.get_user_from_session()
|
||||
timeout = user.send_sms_code()
|
||||
return Response({'code': 'ok','timeout': timeout})
|
||||
|
@@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from authentication import sms_verify_code
|
||||
from common.exceptions import JMSException
|
||||
from .signals import post_auth_failed
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from users.models import MFAType
|
||||
|
||||
reason_password_failed = 'password_failed'
|
||||
reason_password_decrypt_failed = 'password_decrypt_failed'
|
||||
@@ -58,8 +60,18 @@ block_mfa_msg = _(
|
||||
"The account has been locked "
|
||||
"(please contact admin to unlock it or try again after {} minutes)"
|
||||
)
|
||||
mfa_failed_msg = _(
|
||||
"MFA code invalid, or ntp sync server time, "
|
||||
otp_failed_msg = _(
|
||||
"One-time password invalid, or ntp sync server time, "
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
sms_failed_msg = _(
|
||||
"SMS verify code invalid,"
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
mfa_type_failed_msg = _(
|
||||
"The MFA type({mfa_type}) is not supported"
|
||||
"You can also try {times_try} times "
|
||||
"(The account will be temporarily locked for {block_time} minutes)"
|
||||
)
|
||||
@@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = reason_mfa_failed
|
||||
msg: str
|
||||
|
||||
def __init__(self, username, request, ip):
|
||||
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
|
||||
util = MFABlockUtils(username, ip)
|
||||
util.incr_failed_count()
|
||||
|
||||
@@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
|
||||
if times_remainder:
|
||||
self.msg = mfa_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
if mfa_type == MFAType.OTP:
|
||||
self.msg = otp_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
elif mfa_type == MFAType.SMS_CODE:
|
||||
self.msg = sms_failed_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = mfa_type_failed_msg.format(
|
||||
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
|
||||
)
|
||||
else:
|
||||
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, request=request)
|
||||
@@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError):
|
||||
msg = mfa_required_msg
|
||||
error = 'mfa_required'
|
||||
|
||||
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
|
||||
super().__init__(error=error, msg=msg)
|
||||
self.choices = mfa_types
|
||||
|
||||
def as_data(self):
|
||||
return {
|
||||
'error': self.error,
|
||||
'msg': self.msg,
|
||||
'data': {
|
||||
'choices': ['code'],
|
||||
'choices': self.choices,
|
||||
'url': reverse('api-auth:mfa-challenge')
|
||||
}
|
||||
}
|
||||
|
@@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
|
||||
|
||||
|
||||
class UserCheckOtpCodeForm(forms.Form):
|
||||
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
||||
code = forms.CharField(label=_('Code'), max_length=6)
|
||||
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
|
||||
|
||||
|
||||
class CustomCaptchaTextInput(CaptchaTextInput):
|
||||
|
@@ -17,7 +17,7 @@ from django.shortcuts import reverse, redirect
|
||||
from django.views.generic.edit import FormView
|
||||
|
||||
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
|
||||
from users.models import User
|
||||
from users.models import User, MFAType
|
||||
from users.utils import LoginBlockUtil, MFABlockUtils
|
||||
from . import errors
|
||||
from .utils import rsa_decrypt, gen_key_pair
|
||||
@@ -351,13 +351,13 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
||||
unset, url = user.mfa_enabled_but_not_set()
|
||||
if unset:
|
||||
raise errors.MFAUnsetError(user, self.request, url)
|
||||
raise errors.MFARequiredError()
|
||||
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
|
||||
|
||||
def mark_mfa_ok(self):
|
||||
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
|
||||
self.request.session['auth_mfa'] = 1
|
||||
self.request.session['auth_mfa_time'] = time.time()
|
||||
self.request.session['auth_mfa_type'] = 'otp'
|
||||
self.request.session['auth_mfa_required'] = ''
|
||||
self.request.session['auth_mfa_type'] = mfa_type
|
||||
|
||||
def check_mfa_is_block(self, username, ip, raise_exception=True):
|
||||
if MFABlockUtils(username, ip).is_block():
|
||||
@@ -368,11 +368,11 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
||||
else:
|
||||
return exception
|
||||
|
||||
def check_user_mfa(self, code):
|
||||
def check_user_mfa(self, code, mfa_type=MFAType.OTP):
|
||||
user = self.get_user_from_session()
|
||||
ip = self.get_request_ip()
|
||||
self.check_mfa_is_block(user.username, ip)
|
||||
ok = user.check_mfa(code)
|
||||
ok = user.check_mfa(code, mfa_type=mfa_type)
|
||||
if ok:
|
||||
self.mark_mfa_ok()
|
||||
return
|
||||
@@ -380,7 +380,7 @@ class AuthMixin(PasswordEncryptionViewMixin):
|
||||
raise errors.MFAFailedError(
|
||||
username=user.username,
|
||||
request=self.request,
|
||||
ip=ip
|
||||
ip=ip, mfa_type=mfa_type,
|
||||
)
|
||||
|
||||
def get_ticket(self):
|
||||
|
@@ -17,7 +17,7 @@ __all__ = [
|
||||
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
|
||||
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
|
||||
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
|
||||
'PasswordVerifySerializer',
|
||||
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
|
||||
]
|
||||
|
||||
|
||||
@@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer):
|
||||
return instance
|
||||
|
||||
|
||||
class MFASelectTypeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
|
||||
|
||||
class MFAChallengeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
|
||||
code = serializers.CharField(write_only=True)
|
||||
|
97
apps/authentication/sms_verify_code.py
Normal file
97
apps/authentication/sms_verify_code.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import random
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from common.message.backends.sms.alibaba import AlibabaSMS
|
||||
from common.message.backends.sms import SMS
|
||||
from common.utils import get_logger
|
||||
from common.exceptions import JMSException
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
||||
|
||||
class CodeExpired(JMSException):
|
||||
default_code = 'verify_code_expired'
|
||||
default_detail = _('The verification code has expired. Please resend it')
|
||||
|
||||
|
||||
class CodeError(JMSException):
|
||||
default_code = 'verify_code_error'
|
||||
default_detail = _('The verification code is incorrect')
|
||||
|
||||
|
||||
class CodeSendTooFrequently(JMSException):
|
||||
default_code = 'code_send_too_frequently'
|
||||
default_detail = _('Please wait {} seconds before sending')
|
||||
|
||||
def __init__(self, ttl):
|
||||
super().__init__(detail=self.default_detail.format(ttl))
|
||||
|
||||
|
||||
class VerifyCodeUtil:
|
||||
KEY_TMPL = 'auth-verify_code-{}'
|
||||
TIMEOUT = 60
|
||||
|
||||
def __init__(self, account, key_suffix=None, timeout=None):
|
||||
self.account = account
|
||||
self.key_suffix = key_suffix
|
||||
self.code = ''
|
||||
|
||||
if key_suffix is not None:
|
||||
self.key = self.KEY_TMPL.format(key_suffix)
|
||||
else:
|
||||
self.key = self.KEY_TMPL.format(account)
|
||||
self.timeout = self.TIMEOUT if timeout is None else timeout
|
||||
|
||||
def touch(self):
|
||||
"""
|
||||
生成,保存,发送
|
||||
"""
|
||||
ttl = self.ttl()
|
||||
if ttl > 0:
|
||||
raise CodeSendTooFrequently(ttl)
|
||||
|
||||
self.generate()
|
||||
self.save()
|
||||
self.send()
|
||||
|
||||
def generate(self):
|
||||
code = ''.join(random.sample('0123456789', 4))
|
||||
self.code = code
|
||||
return code
|
||||
|
||||
def clear(self):
|
||||
cache.delete(self.key)
|
||||
|
||||
def save(self):
|
||||
cache.set(self.key, self.code, self.timeout)
|
||||
|
||||
def send(self):
|
||||
"""
|
||||
发送信息的方法,如果有错误直接抛出 api 异常
|
||||
"""
|
||||
account = self.account
|
||||
code = self.code
|
||||
|
||||
sms = SMS()
|
||||
sms.send_verify_code(account, code)
|
||||
logger.info(f'Send sms verify code: account={account} code={code}')
|
||||
|
||||
def verify(self, code):
|
||||
right = cache.get(self.key)
|
||||
if not right:
|
||||
raise CodeExpired
|
||||
|
||||
if right != code:
|
||||
raise CodeError
|
||||
|
||||
self.clear()
|
||||
return True
|
||||
|
||||
def ttl(self):
|
||||
return cache.ttl(self.key)
|
||||
|
||||
def get_code(self):
|
||||
return cache.get(self.key)
|
@@ -9,24 +9,60 @@
|
||||
{% block content %}
|
||||
<form class="m-t" role="form" method="post" action="">
|
||||
{% csrf_token %}
|
||||
{% if 'otp_code' in form.errors %}
|
||||
<p class="red-fonts">{{ form.otp_code.errors.as_text }}</p>
|
||||
{% if 'code' in form.errors %}
|
||||
<p class="red-fonts">{{ form.code.errors.as_text }}</p>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<select class="form-control">
|
||||
<option value="otp" selected>{% trans 'One-time password' %}</option>
|
||||
<select id="verify-method-select" name="mfa_type" class="form-control" onchange="select_change(this.value)">
|
||||
{% for method in methods %}
|
||||
<option value="{{ method.name }}" {% if method.selected %} selected {% endif %} {% if not method.enable %} disabled {% endif %}>{{ method.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="otp_code" placeholder="" required="" autofocus="autofocus">
|
||||
<input type="text" class="form-control" name="code" placeholder="" required="" autofocus="autofocus">
|
||||
<span class="help-block">
|
||||
{% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %}
|
||||
{% trans 'Please enter the verification code' %}
|
||||
</span>
|
||||
</div>
|
||||
<button id='send-sms-verify-code' class="btn btn-primary full-width m-b" onclick="sendSMSVerifyCode()" style="display: none">{% trans 'Send verification code' %}</button>
|
||||
<button type="submit" class="btn btn-primary block full-width m-b">{% trans 'Next' %}</button>
|
||||
|
||||
<div>
|
||||
<small>{% trans "Can't provide security? Please contact the administrator!" %}</small>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
|
||||
var methodSelect = document.getElementById('verify-method-select');
|
||||
if (methodSelect.value !== null) {
|
||||
select_change(methodSelect.value);
|
||||
}
|
||||
|
||||
function select_change(type){
|
||||
var currentBtn = document.getElementById('send-sms-verify-code');
|
||||
|
||||
if (type == "sms") {
|
||||
currentBtn.style.display = "block";
|
||||
}
|
||||
else {
|
||||
currentBtn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function sendSMSVerifyCode(){
|
||||
var url = "{% url 'api-auth:sms-verify-code-send' %}";
|
||||
requestApi({
|
||||
url: url,
|
||||
method: "POST",
|
||||
success: function (data) {
|
||||
alert('验证码已发送');
|
||||
},
|
||||
error: function (text, data) {
|
||||
alert(data.detail)
|
||||
},
|
||||
flash_message: false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@@ -27,7 +27,9 @@ urlpatterns = [
|
||||
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
|
||||
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
|
||||
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
|
||||
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
|
||||
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
|
||||
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
|
||||
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
|
||||
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
|
||||
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')
|
||||
|
@@ -4,7 +4,6 @@ import base64
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.Cipher import PKCS1_v1_5
|
||||
from Cryptodome import Random
|
||||
|
||||
from common.utils import get_logger
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
@@ -3,6 +3,8 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
from django.views.generic.edit import FormView
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
from .. import forms, errors, mixins
|
||||
from .utils import redirect_to_guard_view
|
||||
|
||||
@@ -18,12 +20,14 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
|
||||
redirect_field_name = 'next'
|
||||
|
||||
def form_valid(self, form):
|
||||
otp_code = form.cleaned_data.get('otp_code')
|
||||
otp_code = form.cleaned_data.get('code')
|
||||
mfa_type = form.cleaned_data.get('mfa_type')
|
||||
|
||||
try:
|
||||
self.check_user_mfa(otp_code)
|
||||
self.check_user_mfa(otp_code, mfa_type)
|
||||
return redirect_to_guard_view()
|
||||
except (errors.MFAFailedError, errors.BlockMFAError) as e:
|
||||
form.add_error('otp_code', e.msg)
|
||||
form.add_error('code', e.msg)
|
||||
return super().form_invalid(form)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
@@ -31,3 +35,28 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
|
||||
traceback.print_exception()
|
||||
return redirect_to_guard_view()
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
user = self.get_user_from_session()
|
||||
context = {
|
||||
'methods': [
|
||||
{
|
||||
'name': 'otp',
|
||||
'label': _('One-time password'),
|
||||
'enable': bool(user.otp_secret_key),
|
||||
'selected': False,
|
||||
},
|
||||
{
|
||||
'name': 'sms',
|
||||
'label': _('SMS'),
|
||||
'enable': bool(user.phone) and settings.AUTH_SMS,
|
||||
'selected': False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
for item in context['methods']:
|
||||
if item['enable']:
|
||||
item['selected'] = True
|
||||
break
|
||||
context.update(kwargs)
|
||||
return context
|
||||
|
Reference in New Issue
Block a user