mirror of https://github.com/jumpserver/jumpserver
feat(authenticaion): 添加登录页面验证码与MFA开关
parent
2a53a20808
commit
c277aec561
|
@ -2,6 +2,7 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from captcha.fields import CaptchaField
|
from captcha.fields import CaptchaField
|
||||||
|
|
||||||
|
@ -21,9 +22,24 @@ class UserLoginForm(forms.Form):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserLoginCaptchaForm(UserLoginForm):
|
class UserCheckOtpCodeForm(forms.Form):
|
||||||
|
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaMixin(forms.Form):
|
||||||
captcha = CaptchaField()
|
captcha = CaptchaField()
|
||||||
|
|
||||||
|
|
||||||
class UserCheckOtpCodeForm(forms.Form):
|
class ChallengeMixin(forms.Form):
|
||||||
otp_code = forms.CharField(label=_('MFA code'), max_length=6)
|
challenge = forms.CharField(label=_('MFA code'), max_length=6,
|
||||||
|
required=False)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_login_form_cls(*, captcha=False):
|
||||||
|
bases = []
|
||||||
|
if settings.SECURITY_LOGIN_CAPTCHA_ENABLED and captcha:
|
||||||
|
bases.append(CaptchaMixin)
|
||||||
|
if settings.SECURITY_LOGIN_CHALLENGE_ENABLED:
|
||||||
|
bases.append(ChallengeMixin)
|
||||||
|
bases.append(UserLoginForm)
|
||||||
|
return type('UserLoginForm', tuple(bases), {})
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
|
from functools import partial
|
||||||
import time
|
import time
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
|
||||||
from common.utils import get_object_or_none, get_request_ip, get_logger
|
from common.utils import get_object_or_none, get_request_ip, get_logger
|
||||||
from users.models import User
|
from users.models import User
|
||||||
|
@ -9,7 +11,7 @@ from users.utils import (
|
||||||
is_block_login, clean_failed_count
|
is_block_login, clean_failed_count
|
||||||
)
|
)
|
||||||
from . import errors
|
from . import errors
|
||||||
from .utils import check_user_valid
|
from .utils import rsa_decrypt
|
||||||
from .signals import post_auth_success, post_auth_failed
|
from .signals import post_auth_success, post_auth_failed
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
@ -54,21 +56,41 @@ class AuthMixin:
|
||||||
self.check_is_block()
|
self.check_is_block()
|
||||||
request = self.request
|
request = self.request
|
||||||
if hasattr(request, 'data'):
|
if hasattr(request, 'data'):
|
||||||
username = request.data.get('username', '')
|
data = request.data
|
||||||
password = request.data.get('password', '')
|
|
||||||
public_key = request.data.get('public_key', '')
|
|
||||||
else:
|
else:
|
||||||
username = request.POST.get('username', '')
|
data = request.POST
|
||||||
password = request.POST.get('password', '')
|
username = data.get('username', '')
|
||||||
public_key = request.POST.get('public_key', '')
|
password = data.get('password', '')
|
||||||
user, error = check_user_valid(
|
challenge = data.get('challenge', '')
|
||||||
request=request, username=username, password=password, public_key=public_key
|
public_key = data.get('public_key', '')
|
||||||
)
|
|
||||||
ip = self.get_request_ip()
|
ip = self.get_request_ip()
|
||||||
|
|
||||||
|
CredentialError = partial(errors.CredentialError, username=username, ip=ip, request=request)
|
||||||
|
|
||||||
|
# 获取解密密钥,对密码进行解密
|
||||||
|
rsa_private_key = request.session.get('rsa_private_key')
|
||||||
|
if rsa_private_key is not None:
|
||||||
|
try:
|
||||||
|
password = rsa_decrypt(password, rsa_private_key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e, exc_info=True)
|
||||||
|
logger.error('Need decrypt password => {}'.format(password))
|
||||||
|
raise CredentialError(error=errors.reason_password_decrypt_failed)
|
||||||
|
|
||||||
|
user = authenticate(request,
|
||||||
|
username=username,
|
||||||
|
password=password + challenge.strip(),
|
||||||
|
public_key=public_key)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise errors.CredentialError(
|
raise CredentialError(error=errors.reason_password_failed)
|
||||||
username=username, error=error, ip=ip, request=request
|
elif user.is_expired:
|
||||||
)
|
raise CredentialError(error=errors.reason_user_inactive)
|
||||||
|
elif not user.is_active:
|
||||||
|
raise CredentialError(error=errors.reason_user_inactive)
|
||||||
|
elif user.password_has_expired:
|
||||||
|
raise CredentialError(error=errors.reason_password_expired)
|
||||||
|
|
||||||
clean_failed_count(username, ip)
|
clean_failed_count(username, ip)
|
||||||
request.session['auth_password'] = 1
|
request.session['auth_password'] = 1
|
||||||
request.session['user_id'] = str(user.id)
|
request.session['user_id'] = str(user.id)
|
||||||
|
|
|
@ -33,6 +33,16 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if form.challenge %}
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="challenge" class="form-control" id="challenge" name="{{ form.challenge.html_name }}" placeholder="{% trans 'MFA code' %}" >
|
||||||
|
{% if form.errors.challenge %}
|
||||||
|
<div class="help-block field-error">
|
||||||
|
<p class="red-fonts">{{ form.errors.challenge.as_text }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{{ form.captcha }}
|
{{ form.captcha }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -67,16 +67,16 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="box-3">
|
<div class="box-3">
|
||||||
<div style="background-color: white">
|
<div style="background-color: white">
|
||||||
<div style="margin-top: 30px;padding-top: 40px;padding-left: 20px;padding-right: 20px;height: 80px">
|
<div style="margin-top: 20px;padding-top: 40px;padding-left: 20px;padding-right: 20px;height: 80px">
|
||||||
<span style="font-size: 21px;font-weight:400;color: #151515;letter-spacing: 0;">{{ JMS_TITLE }}</span>
|
<span style="font-size: 21px;font-weight:400;color: #151515;letter-spacing: 0;">{{ JMS_TITLE }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 12px;color: #999999;letter-spacing: 0;line-height: 18px;margin-top: 18px">
|
<div style="font-size: 12px;color: #999999;letter-spacing: 0;line-height: 18px;margin-top: 18px">
|
||||||
{% trans 'Welcome back, please enter username and password to login' %}
|
{% trans 'Welcome back, please enter username and password to login' %}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 10px">
|
<div style="margin-bottom: 0px">
|
||||||
<div>
|
<div>
|
||||||
<div class="col-md-1"></div>
|
<div class="col-md-1"></div>
|
||||||
<div class="contact-form col-md-10" style="margin-top: 20px;height: 35px">
|
<div class="contact-form col-md-10" style="margin-top: 0px;height: 35px">
|
||||||
<form id="contact-form" action="" method="post" role="form" novalidate="novalidate">
|
<form id="contact-form" action="" method="post" role="form" novalidate="novalidate">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
|
@ -105,6 +105,16 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if form.challenge %}
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="challenge" class="form-control" id="challenge" name="{{ form.challenge.html_name }}" placeholder="{% trans 'MFA code' %}" >
|
||||||
|
{% if form.errors.challenge %}
|
||||||
|
<div class="help-block field-error">
|
||||||
|
<p class="red-fonts">{{ form.errors.challenge.as_text }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px">
|
<div class="form-group" style="height: 50px;margin-bottom: 0;font-size: 13px">
|
||||||
{{ form.captcha }}
|
{{ form.captcha }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,12 +4,9 @@ import base64
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from Crypto.Cipher import PKCS1_v1_5
|
from Crypto.Cipher import PKCS1_v1_5
|
||||||
from Crypto import Random
|
from Crypto import Random
|
||||||
from django.contrib.auth import authenticate
|
|
||||||
|
|
||||||
from common.utils import get_logger
|
from common.utils import get_logger
|
||||||
|
|
||||||
from . import errors
|
|
||||||
|
|
||||||
logger = get_logger(__file__)
|
logger = get_logger(__file__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -41,33 +38,3 @@ def rsa_decrypt(cipher_text, rsa_private_key=None):
|
||||||
cipher = PKCS1_v1_5.new(key)
|
cipher = PKCS1_v1_5.new(key)
|
||||||
message = cipher.decrypt(base64.b64decode(cipher_text.encode()), 'error').decode()
|
message = cipher.decrypt(base64.b64decode(cipher_text.encode()), 'error').decode()
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
def check_user_valid(**kwargs):
|
|
||||||
password = kwargs.pop('password', None)
|
|
||||||
public_key = kwargs.pop('public_key', None)
|
|
||||||
username = kwargs.pop('username', None)
|
|
||||||
request = kwargs.get('request')
|
|
||||||
|
|
||||||
# 获取解密密钥,对密码进行解密
|
|
||||||
rsa_private_key = request.session.get('rsa_private_key')
|
|
||||||
if rsa_private_key is not None:
|
|
||||||
try:
|
|
||||||
password = rsa_decrypt(password, rsa_private_key)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(e, exc_info=True)
|
|
||||||
logger.error('Need decrypt password => {}'.format(password))
|
|
||||||
return None, errors.reason_password_decrypt_failed
|
|
||||||
|
|
||||||
user = authenticate(request, username=username,
|
|
||||||
password=password, public_key=public_key)
|
|
||||||
if not user:
|
|
||||||
return None, errors.reason_password_failed
|
|
||||||
elif user.is_expired:
|
|
||||||
return None, errors.reason_user_inactive
|
|
||||||
elif not user.is_active:
|
|
||||||
return None, errors.reason_user_inactive
|
|
||||||
elif user.password_has_expired:
|
|
||||||
return None, errors.reason_password_expired
|
|
||||||
|
|
||||||
return user, ''
|
|
||||||
|
|
|
@ -22,7 +22,8 @@ from common.utils import get_request_ip, get_object_or_none
|
||||||
from users.utils import (
|
from users.utils import (
|
||||||
redirect_user_first_login_or_index
|
redirect_user_first_login_or_index
|
||||||
)
|
)
|
||||||
from .. import forms, mixins, errors, utils
|
from .. import mixins, errors, utils
|
||||||
|
from ..forms import get_user_login_form_cls
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
@ -35,8 +36,6 @@ __all__ = [
|
||||||
@method_decorator(csrf_protect, name='dispatch')
|
@method_decorator(csrf_protect, name='dispatch')
|
||||||
@method_decorator(never_cache, name='dispatch')
|
@method_decorator(never_cache, name='dispatch')
|
||||||
class UserLoginView(mixins.AuthMixin, FormView):
|
class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
form_class = forms.UserLoginForm
|
|
||||||
form_class_captcha = forms.UserLoginCaptchaForm
|
|
||||||
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
key_prefix_captcha = "_LOGIN_INVALID_{}"
|
||||||
redirect_field_name = 'next'
|
redirect_field_name = 'next'
|
||||||
|
|
||||||
|
@ -87,7 +86,8 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
form.add_error(None, e.msg)
|
form.add_error(None, e.msg)
|
||||||
ip = self.get_request_ip()
|
ip = self.get_request_ip()
|
||||||
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
cache.set(self.key_prefix_captcha.format(ip), 1, 3600)
|
||||||
new_form = self.form_class_captcha(data=form.data)
|
form_cls = get_user_login_form_cls(captcha=True)
|
||||||
|
new_form = form_cls(data=form.data)
|
||||||
new_form._errors = form.errors
|
new_form._errors = form.errors
|
||||||
context = self.get_context_data(form=new_form)
|
context = self.get_context_data(form=new_form)
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
@ -103,9 +103,9 @@ class UserLoginView(mixins.AuthMixin, FormView):
|
||||||
def get_form_class(self):
|
def get_form_class(self):
|
||||||
ip = get_request_ip(self.request)
|
ip = get_request_ip(self.request)
|
||||||
if cache.get(self.key_prefix_captcha.format(ip)):
|
if cache.get(self.key_prefix_captcha.format(ip)):
|
||||||
return self.form_class_captcha
|
return get_user_login_form_cls(captcha=True)
|
||||||
else:
|
else:
|
||||||
return self.form_class
|
return get_user_login_form_cls()
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
# 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用
|
||||||
|
|
|
@ -238,6 +238,8 @@ class Config(dict):
|
||||||
'SECURITY_PASSWORD_LOWER_CASE': False,
|
'SECURITY_PASSWORD_LOWER_CASE': False,
|
||||||
'SECURITY_PASSWORD_NUMBER': False,
|
'SECURITY_PASSWORD_NUMBER': False,
|
||||||
'SECURITY_PASSWORD_SPECIAL_CHAR': False,
|
'SECURITY_PASSWORD_SPECIAL_CHAR': False,
|
||||||
|
'SECURITY_LOGIN_CHALLENGE_ENABLED': False,
|
||||||
|
'SECURITY_LOGIN_CAPTCHA_ENABLED': True,
|
||||||
|
|
||||||
'HTTP_BIND_HOST': '0.0.0.0',
|
'HTTP_BIND_HOST': '0.0.0.0',
|
||||||
'HTTP_LISTEN_PORT': 8080,
|
'HTTP_LISTEN_PORT': 8080,
|
||||||
|
|
|
@ -41,6 +41,8 @@ SECURITY_PASSWORD_RULES = [
|
||||||
SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL
|
SECURITY_MFA_VERIFY_TTL = CONFIG.SECURITY_MFA_VERIFY_TTL
|
||||||
SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA
|
SECURITY_VIEW_AUTH_NEED_MFA = CONFIG.SECURITY_VIEW_AUTH_NEED_MFA
|
||||||
SECURITY_SERVICE_ACCOUNT_REGISTRATION = DYNAMIC.SECURITY_SERVICE_ACCOUNT_REGISTRATION
|
SECURITY_SERVICE_ACCOUNT_REGISTRATION = DYNAMIC.SECURITY_SERVICE_ACCOUNT_REGISTRATION
|
||||||
|
SECURITY_LOGIN_CAPTCHA_ENABLED = CONFIG.SECURITY_LOGIN_CAPTCHA_ENABLED
|
||||||
|
SECURITY_LOGIN_CHALLENGE_ENABLED = CONFIG.SECURITY_LOGIN_CHALLENGE_ENABLED
|
||||||
|
|
||||||
# Terminal other setting
|
# Terminal other setting
|
||||||
TERMINAL_PASSWORD_AUTH = DYNAMIC.TERMINAL_PASSWORD_AUTH
|
TERMINAL_PASSWORD_AUTH = DYNAMIC.TERMINAL_PASSWORD_AUTH
|
||||||
|
|
Loading…
Reference in New Issue