Compare commits

..

2 Commits

Author SHA1 Message Date
wangruidong
3a7ffce6a8 perf: improve login mfa block handling 2026-01-14 17:22:51 +08:00
wangruidong
78628edb6a perf: improve login block handling 2026-01-09 15:30:33 +08:00
8 changed files with 107 additions and 42 deletions

View File

@@ -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):

View File

@@ -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>

View File

@@ -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:

View File

@@ -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': [],

View File

@@ -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

View File

@@ -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,

View File

@@ -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):

View File

@@ -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',