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

This commit is contained in:
xinwen
2021-08-24 14:20:54 +08:00
parent d49d1e1414
commit b1fceca8a6
57 changed files with 1442 additions and 296 deletions

View File

@@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView):
ticket = self.get_ticket()
if ticket:
request.session.pop('auth_ticket_id', '')
ticket.close(processor=request.user)
ticket.close(processor=self.get_user_from_session())
return Response('', status=200)

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
#
import builtins
import time
from django.utils.translation import ugettext as _
from django.conf import settings
@@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView
from rest_framework.serializers import ValidationError
from rest_framework.response import Response
from common.permissions import IsValidUser, NeedMFAVerify
from authentication.sms_verify_code import VerifyCodeUtil
from common.exceptions import JMSException
from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser
from users.models.user import MFAType
from ..serializers import OtpVerifySerializer
from .. import serializers
from .. import errors
from ..mixins import AuthMixin
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi']
__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi']
class MFASelectTypeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
serializer_class = serializers.MFASelectTypeSerializer
def perform_create(self, serializer):
mfa_type = serializer.validated_data['type']
if mfa_type == MFAType.SMS_CODE:
user = self.get_user_from_session()
user.send_sms_code()
class MFAChallengeApi(AuthMixin, CreateAPIView):
@@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView):
try:
user = self.get_user_from_session()
code = serializer.validated_data.get('code')
valid = user.check_mfa(code)
mfa_type = serializer.validated_data.get('type', MFAType.OTP)
valid = user.check_mfa(code, mfa_type=mfa_type)
if not valid:
self.request.session['auth_mfa'] = ''
raise errors.MFAFailedError(
@@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView):
if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA:
self.permission_classes = [NeedMFAVerify]
return super().get_permissions()
class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView):
permission_classes = (AllowAny,)
def create(self, request, *args, **kwargs):
user = self.get_user_from_session()
timeout = user.send_sms_code()
return Response({'code': 'ok','timeout': timeout})

View File

@@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _
from django.urls import reverse
from django.conf import settings
from authentication import sms_verify_code
from common.exceptions import JMSException
from .signals import post_auth_failed
from users.utils import LoginBlockUtil, MFABlockUtils
from users.models import MFAType
reason_password_failed = 'password_failed'
reason_password_decrypt_failed = 'password_decrypt_failed'
@@ -58,8 +60,18 @@ block_mfa_msg = _(
"The account has been locked "
"(please contact admin to unlock it or try again after {} minutes)"
)
mfa_failed_msg = _(
"MFA code invalid, or ntp sync server time, "
otp_failed_msg = _(
"One-time password invalid, or ntp sync server time, "
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
sms_failed_msg = _(
"SMS verify code invalid,"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
mfa_type_failed_msg = _(
"The MFA type({mfa_type}) is not supported"
"You can also try {times_try} times "
"(The account will be temporarily locked for {block_time} minutes)"
)
@@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
error = reason_mfa_failed
msg: str
def __init__(self, username, request, ip):
def __init__(self, username, request, ip, mfa_type=MFAType.OTP):
util = MFABlockUtils(username, ip)
util.incr_failed_count()
@@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
if times_remainder:
self.msg = mfa_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
if mfa_type == MFAType.OTP:
self.msg = otp_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
elif mfa_type == MFAType.SMS_CODE:
self.msg = sms_failed_msg.format(
times_try=times_remainder, block_time=block_time
)
else:
self.msg = mfa_type_failed_msg.format(
mfa_type=mfa_type, times_try=times_remainder, block_time=block_time
)
else:
self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
super().__init__(username=username, request=request)
@@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError):
msg = mfa_required_msg
error = 'mfa_required'
def __init__(self, error='', msg='', mfa_types=tuple(MFAType)):
super().__init__(error=error, msg=msg)
self.choices = mfa_types
def as_data(self):
return {
'error': self.error,
'msg': self.msg,
'data': {
'choices': ['code'],
'choices': self.choices,
'url': reverse('api-auth:mfa-challenge')
}
}

View File

@@ -43,7 +43,8 @@ class UserLoginForm(forms.Form):
class UserCheckOtpCodeForm(forms.Form):
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
code = forms.CharField(label=_('Code'), max_length=6)
mfa_type = forms.CharField(label=_('MFA type'), max_length=6)
class CustomCaptchaTextInput(CaptchaTextInput):

View File

@@ -17,7 +17,7 @@ from django.shortcuts import reverse, redirect
from django.views.generic.edit import FormView
from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil
from users.models import User
from users.models import User, MFAType
from users.utils import LoginBlockUtil, MFABlockUtils
from . import errors
from .utils import rsa_decrypt, gen_key_pair
@@ -351,13 +351,13 @@ class AuthMixin(PasswordEncryptionViewMixin):
unset, url = user.mfa_enabled_but_not_set()
if unset:
raise errors.MFAUnsetError(user, self.request, url)
raise errors.MFARequiredError()
raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types())
def mark_mfa_ok(self):
def mark_mfa_ok(self, mfa_type=MFAType.OTP):
self.request.session['auth_mfa'] = 1
self.request.session['auth_mfa_time'] = time.time()
self.request.session['auth_mfa_type'] = 'otp'
self.request.session['auth_mfa_required'] = ''
self.request.session['auth_mfa_type'] = mfa_type
def check_mfa_is_block(self, username, ip, raise_exception=True):
if MFABlockUtils(username, ip).is_block():
@@ -368,11 +368,11 @@ class AuthMixin(PasswordEncryptionViewMixin):
else:
return exception
def check_user_mfa(self, code):
def check_user_mfa(self, code, mfa_type=MFAType.OTP):
user = self.get_user_from_session()
ip = self.get_request_ip()
self.check_mfa_is_block(user.username, ip)
ok = user.check_mfa(code)
ok = user.check_mfa(code, mfa_type=mfa_type)
if ok:
self.mark_mfa_ok()
return
@@ -380,7 +380,7 @@ class AuthMixin(PasswordEncryptionViewMixin):
raise errors.MFAFailedError(
username=user.username,
request=self.request,
ip=ip
ip=ip, mfa_type=mfa_type,
)
def get_ticket(self):

View File

@@ -17,7 +17,7 @@ __all__ = [
'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer',
'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer',
'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer',
'PasswordVerifySerializer',
'PasswordVerifySerializer', 'MFASelectTypeSerializer',
]
@@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer):
return instance
class MFASelectTypeSerializer(serializers.Serializer):
type = serializers.CharField()
class MFAChallengeSerializer(serializers.Serializer):
type = serializers.CharField(write_only=True, required=False, allow_blank=True)
code = serializers.CharField(write_only=True)

View File

@@ -0,0 +1,97 @@
import random
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from common.message.backends.sms.alibaba import AlibabaSMS
from common.message.backends.sms import SMS
from common.utils import get_logger
from common.exceptions import JMSException
logger = get_logger(__file__)
class CodeExpired(JMSException):
default_code = 'verify_code_expired'
default_detail = _('The verification code has expired. Please resend it')
class CodeError(JMSException):
default_code = 'verify_code_error'
default_detail = _('The verification code is incorrect')
class CodeSendTooFrequently(JMSException):
default_code = 'code_send_too_frequently'
default_detail = _('Please wait {} seconds before sending')
def __init__(self, ttl):
super().__init__(detail=self.default_detail.format(ttl))
class VerifyCodeUtil:
KEY_TMPL = 'auth-verify_code-{}'
TIMEOUT = 60
def __init__(self, account, key_suffix=None, timeout=None):
self.account = account
self.key_suffix = key_suffix
self.code = ''
if key_suffix is not None:
self.key = self.KEY_TMPL.format(key_suffix)
else:
self.key = self.KEY_TMPL.format(account)
self.timeout = self.TIMEOUT if timeout is None else timeout
def touch(self):
"""
生成,保存,发送
"""
ttl = self.ttl()
if ttl > 0:
raise CodeSendTooFrequently(ttl)
self.generate()
self.save()
self.send()
def generate(self):
code = ''.join(random.sample('0123456789', 4))
self.code = code
return code
def clear(self):
cache.delete(self.key)
def save(self):
cache.set(self.key, self.code, self.timeout)
def send(self):
"""
发送信息的方法,如果有错误直接抛出 api 异常
"""
account = self.account
code = self.code
sms = SMS()
sms.send_verify_code(account, code)
logger.info(f'Send sms verify code: account={account} code={code}')
def verify(self, code):
right = cache.get(self.key)
if not right:
raise CodeExpired
if right != code:
raise CodeError
self.clear()
return True
def ttl(self):
return cache.ttl(self.key)
def get_code(self):
return cache.get(self.key)

View File

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

View File

@@ -27,7 +27,9 @@ urlpatterns = [
path('auth/', api.TokenCreateApi.as_view(), name='user-auth'),
path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'),
path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'),
path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'),
path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'),
path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'),
path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'),
path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'),
path('login-confirm-settings/<uuid:user_id>/', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update')

View File

@@ -4,7 +4,6 @@ import base64
from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from Cryptodome import Random
from common.utils import get_logger
logger = get_logger(__file__)

View File

@@ -3,6 +3,8 @@
from __future__ import unicode_literals
from django.views.generic.edit import FormView
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from .. import forms, errors, mixins
from .utils import redirect_to_guard_view
@@ -18,12 +20,14 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
redirect_field_name = 'next'
def form_valid(self, form):
otp_code = form.cleaned_data.get('otp_code')
otp_code = form.cleaned_data.get('code')
mfa_type = form.cleaned_data.get('mfa_type')
try:
self.check_user_mfa(otp_code)
self.check_user_mfa(otp_code, mfa_type)
return redirect_to_guard_view()
except (errors.MFAFailedError, errors.BlockMFAError) as e:
form.add_error('otp_code', e.msg)
form.add_error('code', e.msg)
return super().form_invalid(form)
except Exception as e:
logger.error(e)
@@ -31,3 +35,28 @@ class UserLoginOtpView(mixins.AuthMixin, FormView):
traceback.print_exception()
return redirect_to_guard_view()
def get_context_data(self, **kwargs):
user = self.get_user_from_session()
context = {
'methods': [
{
'name': 'otp',
'label': _('One-time password'),
'enable': bool(user.otp_secret_key),
'selected': False,
},
{
'name': 'sms',
'label': _('SMS'),
'enable': bool(user.phone) and settings.AUTH_SMS,
'selected': False,
},
]
}
for item in context['methods']:
if item['enable']:
item['selected'] = True
break
context.update(kwargs)
return context