mirror of
https://github.com/jumpserver/jumpserver.git
synced 2026-01-14 12:06:23 +00:00
Compare commits
2 Commits
dependabot
...
pr@dev@per
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a7ffce6a8 | ||
|
|
78628edb6a |
@@ -37,6 +37,7 @@ class AuthFailedError(Exception):
|
||||
error = ''
|
||||
request = None
|
||||
ip = ''
|
||||
block_ttl = 0
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
@@ -73,12 +74,13 @@ class CredentialError(
|
||||
def __init__(self, error, username, ip, request):
|
||||
util = LoginBlockUtil(username, ip)
|
||||
times_remainder = util.get_remainder_times()
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
if times_remainder < 1:
|
||||
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
self.block_time = util.get_block_ttl_min()
|
||||
self.block_ttl = util.get_block_ttl()
|
||||
self.msg = const.block_user_login_msg.format(self.block_time)
|
||||
else:
|
||||
default_msg = const.invalid_login_msg.format(
|
||||
times_try=times_remainder, block_time=block_time
|
||||
times_try=times_remainder, block_time=1
|
||||
)
|
||||
if error == const.reason_password_failed:
|
||||
self.msg = default_msg
|
||||
@@ -94,15 +96,17 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
|
||||
def __init__(self, username, request, ip, mfa_type, error):
|
||||
util = MFABlockUtils(username, ip)
|
||||
times_remainder = util.incr_failed_count()
|
||||
block_time = settings.SECURITY_LOGIN_LIMIT_TIME
|
||||
util.incr_failed_count()
|
||||
times_remainder = util.get_remainder_times()
|
||||
|
||||
if times_remainder:
|
||||
if times_remainder > 1:
|
||||
self.msg = const.mfa_error_msg.format(
|
||||
error=error, times_try=times_remainder, block_time=block_time
|
||||
error=error, times_try=times_remainder, block_time=1
|
||||
)
|
||||
else:
|
||||
self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
self.block_time = util.get_block_ttl_min()
|
||||
self.block_ttl = util.get_block_ttl()
|
||||
self.msg = const.block_mfa_msg.format(self.block_time)
|
||||
super().__init__(username=username, request=request)
|
||||
|
||||
|
||||
@@ -110,7 +114,10 @@ class BlockMFAError(AuthFailedNeedLogMixin, AuthFailedError):
|
||||
error = 'block_mfa'
|
||||
|
||||
def __init__(self, username, request, ip):
|
||||
self.msg = const.block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
util = MFABlockUtils(username, ip)
|
||||
self.block_time = util.get_block_ttl_min()
|
||||
self.block_ttl = util.get_block_ttl()
|
||||
self.msg = const.block_mfa_msg.format(self.block_time)
|
||||
super().__init__(username=username, request=request, ip=ip)
|
||||
|
||||
|
||||
@@ -118,8 +125,11 @@ class BlockLoginError(AuthFailedNeedLogMixin, AuthFailedNeedBlockMixin, AuthFail
|
||||
error = 'block_login'
|
||||
|
||||
def __init__(self, username, ip, request):
|
||||
self.msg = const.block_user_login_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME)
|
||||
super().__init__(username=username, ip=ip, request=request)
|
||||
util = LoginBlockUtil(username, ip)
|
||||
self.block_time = util.get_block_ttl_min()
|
||||
self.block_ttl = util.get_block_ttl()
|
||||
self.msg = const.block_user_login_msg.format(self.block_time)
|
||||
super().__init__(username=username, ip=ip, request=request, block_ttl=self.block_ttl)
|
||||
|
||||
|
||||
class SessionEmptyError(AuthFailedError):
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<style>
|
||||
.login-content {
|
||||
{% comment %} box-shadow: 0 5px 5px -3px rgb(0 0 0 / 15%), 0 8px 10px 1px rgb(0 0 0 / 14%), 0 3px 14px 2px rgb(0 0 0 / 12%); {% endcomment %}
|
||||
{% comment %} box-shadow: 0 5px 5px -3px rgb(0 0 0 / 15%), 0 8px 10px 1px rgb(0 0 0 / 14%), 0 3px 14px 2px rgb(0 0 0 / 12%); {% endcomment %}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
@@ -86,7 +86,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #f3f3f3;
|
||||
{#height: calc(100vh - (100vh - 470px) / 3);#}
|
||||
{#height: calc(100vh - (100vh - 470px) / 3);#}
|
||||
}
|
||||
|
||||
|
||||
@@ -406,7 +406,6 @@
|
||||
{% trans 'Login' %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if demo_mode %}
|
||||
<div>
|
||||
<p class="red-fonts" style='text-align: center;'>
|
||||
@@ -463,6 +462,49 @@
|
||||
}
|
||||
|
||||
setInterval(checkHealth, 30 * 1000);
|
||||
|
||||
{% if block_ttl %}
|
||||
(function () {
|
||||
var ttl = {{ block_ttl }};
|
||||
var loginBtn = $('button[type="submit"]');
|
||||
var inputs = $('#login-form input');
|
||||
var originalBtnText = loginBtn.text();
|
||||
var intervalId = null;
|
||||
|
||||
// Only disable password and other inputs, keep username enabled
|
||||
inputs.not('[name="username"]').prop('disabled', true);
|
||||
|
||||
function updateState() {
|
||||
if (ttl <= 0) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
var minutes = Math.floor(ttl / 60);
|
||||
var seconds = ttl % 60;
|
||||
var timeStr = "";
|
||||
if (minutes > 0) {
|
||||
timeStr += minutes + "m ";
|
||||
}
|
||||
timeStr += seconds + "s";
|
||||
|
||||
loginBtn.text("{% trans 'Login blocked' %} (" + timeStr + ")");
|
||||
loginBtn.prop('disabled', true);
|
||||
ttl--;
|
||||
}
|
||||
|
||||
updateState();
|
||||
intervalId = setInterval(updateState, 1000);
|
||||
|
||||
// Allow switching account: unlock UI when username changes
|
||||
$('input[name="username"]').one('input', function () {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
loginBtn.text(originalBtnText);
|
||||
loginBtn.prop('disabled', false);
|
||||
inputs.prop('disabled', false);
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -214,6 +214,8 @@ class UserLoginView(mixins.AuthMixin, UserLoginContextMixin, FormView):
|
||||
new_form = form_cls(data=form.data)
|
||||
new_form._errors = form.errors
|
||||
context = self.get_context_data(form=new_form)
|
||||
if getattr(e, 'block_ttl', 0) > 0:
|
||||
context['block_ttl'] = e.block_ttl
|
||||
self.request.session.set_test_cookie()
|
||||
return self.render_to_response(context)
|
||||
except errors.NeedRedirectError as e:
|
||||
|
||||
@@ -615,7 +615,6 @@ class Config(dict):
|
||||
'PRIVACY_MODE': False,
|
||||
# 用户登录限制的规则
|
||||
'SECURITY_LOGIN_LIMIT_COUNT': 7,
|
||||
'SECURITY_LOGIN_LIMIT_TIME': 30,
|
||||
# 登录IP限制的规则
|
||||
'SECURITY_LOGIN_IP_BLACK_LIST': [],
|
||||
'SECURITY_LOGIN_IP_WHITE_LIST': [],
|
||||
|
||||
@@ -72,7 +72,6 @@ SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = CONFIG.SECURITY_INSECURE_COMMAND_EMAI
|
||||
SECURITY_CHECK_DIFFERENT_CITY_LOGIN = CONFIG.SECURITY_CHECK_DIFFERENT_CITY_LOGIN
|
||||
# 用户登录限制的规则
|
||||
SECURITY_LOGIN_LIMIT_COUNT = CONFIG.SECURITY_LOGIN_LIMIT_COUNT
|
||||
SECURITY_LOGIN_LIMIT_TIME = CONFIG.SECURITY_LOGIN_LIMIT_TIME # Unit: minute
|
||||
# 登录IP限制的规则
|
||||
SECURITY_LOGIN_IP_BLACK_LIST = CONFIG.SECURITY_LOGIN_IP_BLACK_LIST
|
||||
SECURITY_LOGIN_IP_WHITE_LIST = CONFIG.SECURITY_LOGIN_IP_WHITE_LIST
|
||||
|
||||
@@ -69,11 +69,6 @@ class SecurityLoginLimitSerializer(serializers.Serializer):
|
||||
min_value=3, max_value=99999,
|
||||
label=_('Login failures count')
|
||||
)
|
||||
SECURITY_LOGIN_LIMIT_TIME = serializers.IntegerField(
|
||||
min_value=5, max_value=99999, required=True,
|
||||
label=_('Login failure period (minute)'),
|
||||
help_text=login_ip_limit_time_help_text
|
||||
)
|
||||
|
||||
SECURITY_LOGIN_IP_LIMIT_COUNT = serializers.IntegerField(
|
||||
min_value=3, max_value=99999,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#
|
||||
import base64
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
@@ -116,24 +117,10 @@ def check_password_rules(password, is_org_admin=False):
|
||||
return bool(match_obj)
|
||||
|
||||
|
||||
class BlockUtil:
|
||||
BLOCK_KEY_TMPL: str
|
||||
|
||||
def __init__(self, username):
|
||||
username = username.lower()
|
||||
self.block_key = self.BLOCK_KEY_TMPL.format(username)
|
||||
self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60
|
||||
|
||||
def block(self):
|
||||
cache.set(self.block_key, True, self.key_ttl)
|
||||
|
||||
def is_block(self):
|
||||
return bool(cache.get(self.block_key))
|
||||
|
||||
|
||||
class BlockUtilBase:
|
||||
LIMIT_KEY_TMPL: str
|
||||
BLOCK_KEY_TMPL: str
|
||||
TIERS = [1, 5, 15, 60]
|
||||
|
||||
def __init__(self, username, ip):
|
||||
username = username.lower() if username else ''
|
||||
@@ -141,7 +128,6 @@ class BlockUtilBase:
|
||||
self.ip = ip
|
||||
self.limit_key = self.LIMIT_KEY_TMPL.format(username)
|
||||
self.block_key = self.BLOCK_KEY_TMPL.format(username)
|
||||
self.key_ttl = int(settings.SECURITY_LOGIN_LIMIT_TIME) * 60
|
||||
|
||||
def get_remainder_times(self):
|
||||
times_up = settings.SECURITY_LOGIN_LIMIT_COUNT
|
||||
@@ -149,15 +135,47 @@ class BlockUtilBase:
|
||||
times_remainder = int(times_up) - int(times_failed)
|
||||
return times_remainder
|
||||
|
||||
def get_block_ttl_min(self):
|
||||
"""Return the current block TTL in minutes."""
|
||||
ttl = self.get_block_ttl()
|
||||
return math.ceil(ttl / 60)
|
||||
|
||||
def get_block_ttl(self):
|
||||
"""Return the precise TTL in seconds."""
|
||||
ttl = cache.ttl(self.block_key)
|
||||
if ttl is None or ttl <= 0:
|
||||
return 0
|
||||
return ttl
|
||||
|
||||
@classmethod
|
||||
def get_block_time_by_count(cls, count, limit_count=None):
|
||||
if limit_count is None:
|
||||
limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT
|
||||
|
||||
if count < limit_count:
|
||||
return 0
|
||||
|
||||
index = count - limit_count
|
||||
if index < len(cls.TIERS):
|
||||
return cls.TIERS[index]
|
||||
else:
|
||||
return cls.TIERS[-1]
|
||||
|
||||
def incr_failed_count(self) -> int:
|
||||
# 当前被 block 直接返回
|
||||
if self.is_block():
|
||||
return 0
|
||||
limit_key = self.limit_key
|
||||
count = cache.get(limit_key, 0)
|
||||
count += 1
|
||||
cache.set(limit_key, count, self.key_ttl)
|
||||
|
||||
# 尝试次数设为最大锁定时间,防止缓存过期后无法锁定
|
||||
cache.set(limit_key, count, self.TIERS[-1] * 60)
|
||||
limit_count = settings.SECURITY_LOGIN_LIMIT_COUNT
|
||||
if count >= limit_count:
|
||||
cache.set(self.block_key, True, self.key_ttl)
|
||||
# 阶梯式锁定: 1m, 5m, 15m, 60m
|
||||
minutes = self.get_block_time_by_count(count, limit_count)
|
||||
timeout = minutes * 60
|
||||
cache.set(self.block_key, True, timeout)
|
||||
return limit_count - count
|
||||
|
||||
def get_failed_count(self):
|
||||
|
||||
@@ -35,7 +35,7 @@ dependencies = [
|
||||
'gmssl==3.2.2',
|
||||
'itsdangerous==1.1.0',
|
||||
'pyotp==2.8.0',
|
||||
'pynacl==1.6.2',
|
||||
'pynacl==1.5.0',
|
||||
'python-dateutil==2.8.2',
|
||||
'pyyaml==6.0.1',
|
||||
'requests==2.32.4',
|
||||
|
||||
Reference in New Issue
Block a user